diff --git a/.cspell.yaml b/.cspell.yaml index 4274ee66..e32c3064 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -14,6 +14,70 @@ language: en # Project-specific technical terms and tool names words: + - dctdecode + - Lsiz + - smaskindata + - psnr + - bitplanes + - bitstream + - Cdef + - codestreams + - coef + - Colr + - CPRL + - csiz + - dequantization + - Dmax + - Dmin + - dplanes + - EBCOT + - fplane + - INITDEC + - INITENC + - Ippm + - Irrev + - isot + - Lblock + - LRCP + - lsot + - luts + - Mallat + - mant + - Memberwise + - NLPS + - NMPS + - Nppm + - Osiz + - Pclr + - Pcoc + - PCRL + - Prec + - Psot + - TNsot + - TPsot + - pterm + - Renorm + - RLCP + - RPCL + - rsiz + - scoc + - scod + - segsym + - signedness + - smask + - ssiz + - subband + - Sycc + - Termall + - Tsiz + - vcausal + - vsum + - xmap + - xmaps + - Xsiz + - ymaps + - ysiz + - Zppm - Aabb - functiontype - Poppler @@ -235,6 +299,10 @@ words: - Argb - cmap - Cmap + - Lsop + - ftyp + - bpcc + - superbox - codepoint - Dzik - glyf diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 644c749f..3c4a8e10 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -254,6 +254,31 @@ reviews: - "test/DemaConsulting.CanvasNet.Tests/Codecs/GifCodecTests.cs" # unit tests - "test/DemaConsulting.CanvasNet.Tests/Codecs/GifFixtureTests.cs" # fixture conformance tests + # CanvasNet-Codecs-Jpeg2000Codec Review (one per unit) + - id: CanvasNet-Codecs-Jpeg2000Codec + title: Review that CanvasNet Codecs Jpeg2000Codec Implementation is Correct + context: + - "docs/design/canvas-net.md" + - "docs/reqstream/canvas-net.yaml" + - "docs/design/canvas-net/codecs.md" + - "docs/reqstream/canvas-net/codecs.yaml" + paths: + - "docs/reqstream/canvas-net/codecs/jpeg2000-codec.yaml" # unit requirements + - "docs/design/canvas-net/codecs/jpeg2000-codec.md" # unit design + - "docs/verification/canvas-net/codecs/jpeg2000-codec.md" # unit verification + - "src/DemaConsulting.CanvasNet/Codecs/Jpeg2000Codec.cs" # unit source (public API entry point) + - "src/DemaConsulting.CanvasNet/Codecs/Jpeg2000Image.cs" # unit source (public decoded-image result type) + - "src/DemaConsulting.CanvasNet/Codecs/Jpeg2000ColorSpace.cs" # unit source (public color-space enum) + - "src/DemaConsulting.CanvasNet/Codecs/Jpeg2000DecoderLimits.cs" # unit source (public decoder resource limits) + - "src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/**/*.cs" # unit source (partial-class implementation files) + - "src/DemaConsulting.CanvasNet/Codecs/ImageInfo.cs" # shared header-only probe result type + - "src/DemaConsulting.CanvasNet/Codecs/NamespaceDoc.cs" # Codecs namespace documentation + - "test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000CodecTests.cs" # unit tests + - "test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000ValidationTests.cs" # fail-closed validation tests + - "test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000TestEncoder*.cs" # test-only JPEG 2000 encoder + - "test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000FixtureTests.cs" # real-encoder fixture tests + - "test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/**" # real-encoder fixture corpus + README + # CanvasNet-Geometry Review (one per subsystem) - id: CanvasNet-Geometry title: Review that CanvasNet Geometry Subsystem Implementation is Correct @@ -770,6 +795,10 @@ reviews: - "src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs" # Pdf namespace documentation - "test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs" # unit tests - "test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentMeshShadingTests.cs" # mesh shading unit tests + - "test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentJpxTests.cs" # JPXDecode/SMask unit tests + - "test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/gray.jp2" # gray JPX fixture + - "test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/source_gray.png" # source pixels of gray.jp2 + - "test/DemaConsulting.CanvasNet.Tests/JpegFixtures/grayscale_baseline.jpg" # gray DCT fixture - "test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs" # integration tests - "test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtureTests.cs" # fixture conformance tests diff --git a/README.md b/README.md index 4f0ba6f2..10453dd8 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ image operations using `Span`, and supports independent-copy cropping for loa - ๐Ÿ–จ๏ธ **TIFF Codec** - Load/save 8-bit RGB/RGBA/Grayscale TIFF files - ๐Ÿ—œ๏ธ **JPEG Codec** - Load baseline/progressive; save baseline JPEG - ๐ŸŽž๏ธ **GIF Codec** - Decode-only load of first GIF frame; `GetInfo` reports the true frame count +- ๐Ÿ–ผ๏ธ **JPEG 2000 Codec** - Decode-only load of JP2 files and raw codestreams (Decode also exposes color space and alpha) - ๐Ÿ“ **SVG Codec** - Rasterize a common SVG subset to a surface (`DemaConsulting.CanvasNet.Svg`) - ๐Ÿ“„ **PDF Document** - Open, inspect, and rasterize PDF pages with font substitution (`DemaConsulting.CanvasNet.Pdf`) - ๐Ÿ“Š **Chart Rendering** - Build and paint Bar/Column/Line/Area/Pie/Doughnut charts (`DemaConsulting.CanvasNet.Charts`) @@ -88,7 +89,7 @@ PngCodec.Save(surface, "surface.png"); using var reloaded = PngCodec.Load("surface.png"); ``` -Open a PDF and render a page (BMP/TIFF/JPEG/GIF/SVG codecs, header-only probing, and +Open a PDF and render a page (BMP/TIFF/JPEG/GIF/JPEG 2000/SVG codecs, header-only probing, and feature-detection all follow the same pattern): ```csharp diff --git a/docs/design/canvas-net-pdf.md b/docs/design/canvas-net-pdf.md index 546bee06..f0b3bba4 100644 --- a/docs/design/canvas-net-pdf.md +++ b/docs/design/canvas-net-pdf.md @@ -47,9 +47,10 @@ size โ€” but every filled/stroked path painted in solid opaque black, since no c implemented yet. Phase 3 added real device color (`g`/`G`/`rg`/`RG`/`k`/`K`/`cs`/ `CS`/`sc`/`SC`/`scn`/`SCN`), a generalized `/Filter`/`/DecodeParms` stream-decoding pipeline (`FlateDecode` plus PNG/TIFF predictor reversal), and image XObjects (`Do`: `DCTDecode` via the -`CanvasNet` system's `Codecs.JpegCodec`, or raw `DeviceGray`/`DeviceRGB`/`DeviceCMYK` 8-bit -samples, composited through the current transformation matrix). Phase 4 added real text/font -rendering: `BT`/`ET`/`Tc`/`Tw`/`Tz`/`TL`/`Tf`/`Tr`/`Ts`/`Td`/`TD`/`Tm`/`T*`/`Tj`/`'`/`"`/ +`CanvasNet` system's `Codecs.JpegCodec`, `JPXDecode` via `Codecs.Jpeg2000Codec`, or raw +`DeviceGray`/`DeviceRGB`/`DeviceCMYK`/`Indexed` samples of 1/2/4/8/16 bits, composited through the +current transformation matrix). +Phase 4 added real text/font rendering: `BT`/`ET`/`Tc`/`Tw`/`Tz`/`TL`/`Tf`/`Tr`/`Ts`/`Td`/`TD`/`Tm`/`T*`/`Tj`/`'`/`"`/ `TJ` resolve a simple `/Subtype /TrueType` font from the current page's `/Resources/Font` dictionary (requiring an embedded `/FontDescriptor/FontFile2`, loaded via the `CanvasNet` system's `Fonts.TrueTypeFont`), map each shown byte through its `/WinAnsiEncoding`/ @@ -106,9 +107,9 @@ Form XObject's `/BBox` is never used to clip its content and its `/Group` (trans entry is never consulted, though the Form itself renders; `/ShadingType` values outside `2`-`7` and `/FunctionType 4` (PostScript calculator) functions fail closed; the `sh` operator and general path clipping (`W`/`W*`) are supported; no -`/SMask`/alpha compositing or transparency groups (every decoded image is treated as fully -opaque); no `JPXDecode` filter decoding (fails closed; `CCITTFaxDecode` - Group 4 (T.6 MMR) only - -is supported); `/Separation`/`/DeviceN`/`/CalGray`/`/Lab` color spaces remain unsupported and fail +`/Mask`/`/Matte` image masks or transparency groups (an explicit `/SMask` and JPEG 2000 +alpha are applied); `CCITTFaxDecode` - Group 4 (T.6 MMR) only - is supported and `JPXDecode` +is decoded; `/Separation`/`/DeviceN`/`/CalGray`/`/Lab` color spaces remain unsupported and fail closed; and an encrypted document using any security handler, crypt-filter method, `/V`/`/R` combination other than the ones listed above, or whose correct password is not supplied, fails closed rather than being decrypted. These remain out of scope and are planned for later phases @@ -335,8 +336,13 @@ crypt filter), `pdf-encrypted-r-{revision}`, non-empty one is genuinely required), `pdf-encrypted-incorrect-password`, `pdf-encrypted-password-non-ascii` (an R2-R4 password outside ASCII 0-127), `pdf-colorspace-{name}` (`Separation`/`DeviceN`/`CalGray`/`Lab`), `pdf-colorspace-Pattern`, -`pdf-colorspace-ICCBased`, `pdf-filter-{name}` (`JPXDecode` and any other unrecognized filter), -`pdf-tiff-predictor-bitdepth-{n}`, `pdf-image-bitdepth-{n}`, `pdf-pattern-not-declared`, +`pdf-colorspace-ICCBased`, `pdf-filter-{name}` (any unrecognized filter), +`pdf-tiff-predictor-bitdepth-{n}`, `pdf-image-bitdepth-{n}` (a depth other than 1/2/4/8/16, or a 16-bit `/Indexed` image), +`pdf-jpx-colorspace` (a JPX image of a channel count with no device color space without `/ColorSpace`), +`pdf-jpx-palette-colorspace` (a `/ColorSpace` override on JP2 data with its own palette), +`pdf-jpx-indexed-bit-depth` (an `/Indexed` space over non-8-bit JPX samples), +`pdf-jpx-smaskindata-indexed` (`/SMaskInData 2` over an `/Indexed` space), +`pdf-pattern-not-declared`, `pdf-pattern-type-{n}`, `pdf-pattern-tile-too-large`, `pdf-shading-type-{n}`, `pdf-shading-colorspace-{family}`, `pdf-functiontype-{n}`, `pdf-function-multiinput`, `pdf-function-bitspersample-{n}`, `pdf-font-subtype-{subtype}` (`MMType1` only), diff --git a/docs/design/canvas-net-pdf/pdf-document.md b/docs/design/canvas-net-pdf/pdf-document.md index e842901c..6b64e9c9 100644 --- a/docs/design/canvas-net-pdf/pdf-document.md +++ b/docs/design/canvas-net-pdf/pdf-document.md @@ -34,8 +34,9 @@ filter pipeline, image XObjects (Phase 3), and, as of Phase 4, real embedded-Tru rendering: `g`/`G`/`rg`/`RG`/`k`/`K`/`cs`/`CS`/`sc`/`SC`/`scn`/`SCN` set the actual fill/stroke color a path paints with; a generalized `/Filter`/`/DecodeParms` pipeline (`FlateDecode` plus PNG/TIFF predictor reversal) decodes any stream, not only a page's own `/Contents`; `Do` decodes -and composites a `/Subtype /Image` XObject (`DCTDecode` via `Codecs.JpegCodec`, or raw -`DeviceGray`/`DeviceRGB`/`DeviceCMYK` 8-bit samples) through the current transformation matrix; +and composites a `/Subtype /Image` XObject (`DCTDecode` via `Codecs.JpegCodec`, `JPXDecode` via +`Codecs.Jpeg2000Codec`, or raw `DeviceGray`/`DeviceRGB`/`DeviceCMYK`/`Indexed` samples of +1/2/4/8/16 bits) through the current transformation matrix; and `BT`/`ET`/`Tc`/`Tw`/`Tz`/`TL`/`Tf`/`Tr`/`Ts`/`Td`/`TD`/`Tm`/`T*`/`Tj`/`'`/`"`/`TJ` resolve a simple (non-composite) TrueType font declared in the current page's `/Resources/Font` dictionary, map each shown byte through that font's `/Encoding` to a Unicode codepoint, and paint @@ -91,18 +92,18 @@ supported (an unrecognized base encoding also fails closed); text-rendering mode (stroke), `2` (fill, then stroke), `3` (invisible), and the clip modes `4`-`7` are supported (a Type 3 glyph under a clip mode fails closed); and no additional stream filters were added for any of these phases. **Phase 3 limitations** (narrowed by Phase 7/13 and the `/Pattern` color-space phase, see below): -no transparency groups; shading/tiling pattern fills were added (`/ShadingType 2`/`3`, -`/PatternType 1`/`2` โ€” see _`/Pattern` Color Space (Shading and Tiling Patterns)_ below, reusing -the `/FunctionType 0` sampled-function evaluator originally landed as Phase 1 groundwork, -alongside new `/FunctionType 2`/`3` support), no `JPX` filter decoding (fails closed; -`LZWDecode`/`ASCII85Decode`/`ASCIIHexDecode`/`RunLengthDecode` are supported as of Phase 7, and -`CCITTFaxDecode` - Group 4 (T.6 MMR) only - is supported as of Phase 15, see below), and no -`/SMask`/alpha compositing (every decoded image is treated as fully opaque) โ€” these -remain out of scope for this phase and are silently skipped (any other undefined keyword) or -explicitly rejected (unsupported color spaces/filters/fonts/encodings/render modes), per the -operator/exception taxonomy documented below; a later phase is expected to add transparency -support. As of Phase 13 (see below), `Do` on a `/Subtype /Form` XObject is no longer one of these -rejected constructs: it decodes and executes the Form's content stream, subject to its own +there are no transparency groups, `/Mask` (stencil/color-key) image masks and `/Matte` are not +consulted, and the Phase 3 operator subset is otherwise limited as documented below. These remain +out of scope and are silently skipped (any other undefined keyword) or explicitly rejected +(unsupported color spaces/filters/fonts/encodings/render modes), per the operator/exception +taxonomy documented below. Since then the following have been added: shading/tiling pattern fills +(`/ShadingType 2`/`3`, `/PatternType 1`/`2` โ€” see _`/Pattern` Color Space (Shading and Tiling +Patterns)_ below, reusing the `/FunctionType 0` sampled-function evaluator originally landed as +Phase 1 groundwork, alongside new `/FunctionType 2`/`3` support); the `LZWDecode`/`ASCII85Decode`/ +`ASCIIHexDecode`/`RunLengthDecode` filters (Phase 7); `CCITTFaxDecode` โ€” Group 4 (T.6 MMR) only โ€” +(Phase 15, see below); and `JPXDecode` images and explicit `/SMask` soft masks (see _Image +XObjects_ below). As of Phase 13 (see below), `Do` on a `/Subtype /Form` XObject is no longer one +of the rejected constructs: it decodes and executes the Form's content stream, subject to its own documented Phase 13 limitations (no `/BBox` clipping, no `/Group` transparency-group handling, and a hard recursion-depth limit of 12). @@ -698,8 +699,9 @@ its own distinguishable `Feature` token. `Codecs/Png/PngCodec.Filtering.cs`'s `DefilterRow`/`Sub`/`Up`/`Average`/`Paeth`/Paeth-predictor algorithm, since those methods are `private` in a different assembly and cannot be reused directly). Any other filter name (including `DCTDecode`/`CCITTFaxDecode`, which `PdfDocument.Images.cs` - always detects and bypasses before calling this pipeline; and `JPXDecode`/`JBIG2Decode`/ - `Crypt`, which remain out of scope) throws + always detects and bypasses before calling this pipeline; `JPXDecode` (handled by the dedicated JPX + path described below), and + `JBIG2Decode`/`Crypt`, which remain out of scope) throws `Codecs.UnsupportedImageFeatureException`; a malformed `/Filter`/`/DecodeParms` shape, an unrecognized `/Predictor` value, or malformed bytes for any of the five supported filters (missing EOD marker, invalid/out-of-range code or character, truncated run, or an out-of-range @@ -724,9 +726,47 @@ its own distinguishable `Feature` token. samples per `/ColorSpace` (device spaces, `/ICCBased`, and `/Indexed` โ€” see above, reusing `ColorFromComponents`; an `/Indexed` sample is passed through `SamplesToColor` as a raw, un-normalized palette index rather than divided by `255.0` like every other color space) and - `/BitsPerComponent` (`8` only โ€” anything else throws - `Codecs.UnsupportedImageFeatureException`); `/SMask`/`/Mask` are never consulted (every decoded - image is treated as fully opaque, a documented Phase 3 limitation). + `/BitsPerComponent` (`1`, `2`, `4`, `8` or `16` โ€” `UnpackSamples` normalizes every depth to one + byte per component: rows are padded to whole bytes, non-`/Indexed` samples are scaled to 0-255, + 16-bit samples keep their high byte, `/Indexed` samples stay raw palette indices and may not be + 16-bit; a short sample buffer throws `InvalidDataException`; any other depth throws + `Codecs.UnsupportedImageFeatureException`). A bare `/Filter /JPXDecode` image is detected and + dispatched to `DecodeJpxImageXObject`, which decodes the stream via + `Codecs.Jpeg2000Codec.Decode(byte[], Jpeg2000DecoderLimits)` with explicit limits of + `Surface.MaxDimension` in each dimension, checked by the codec while it parses the headers and so + before any plane is allocated (trusting the decoder's dimensions over `/Width`/`/Height`, like + `DCTDecode`; every `Jpeg2000Codec` failure, including a limit violation, surfaces as the + existing `InvalidDataException`/`UnsupportedImageFeatureException` conventions); + `JPXDecode` combined with any other filter + throws `InvalidDataException`. When `/ColorSpace` is absent the JP2's own color space is used + (gray, sRGB, CMYK, or by channel count; five or more channels need an explicit `/ColorSpace` + and otherwise throw `UnsupportedImageFeatureException`); when present, `/ColorSpace` + (`DeviceGray`/`DeviceRGB`/`DeviceCMYK`, `/Indexed`, `/ICCBased` by component count; `/Pattern` + throws `InvalidDataException`) overrides it + and its component count must equal the decoded color channel count + (`InvalidDataException` otherwise); an `/Indexed` override uses the decoded 8-bit samples as + indices (non-8-bit source samples and JP2 data with its own palette throw + `UnsupportedImageFeatureException`). An optional `/Decode` array is applied for **every** + encoding (raw, `CCITTFaxDecode`, `JPXDecode`, and `/SMask` images) by the shared + `ResolveDecodeArray` (validation; an identity array resolves to none) and `ApplyDecodeArray` + (per-component linear remap of the samples before the color-space conversion; for `/Indexed` + the range is in palette-index units over `0..2^bits-1`); a + wrong-length or non-numeric array throws `InvalidDataException`. A `DCTDecode` image is decoded + by `JpegCodec` to exactly its 1 (gray) or 3 (RGB) component samples, so `ApplyDecodeToSurface` + applies the same `ApplyDecodeArray` remap to them exactly; 4-component (CMYK/YCCK) JPEGs are + rejected outright by `JpegCodec` (`InvalidDataException`), so a `/Decode` is never silently ignored. + `/SMaskInData` selects how the codestream's opacity channel is used: `0` ignores it, `1` applies + it as straight alpha, `2` un-premultiplies the component samples (`UnpremultiplySamples`, + before `/Decode` and before the color-space conversion, since e.g. CMYK to RGB is not linear in + the premultiplied values; an `/Indexed` space throws `UnsupportedImageFeatureException`, + `pdf-jpx-smaskindata-indexed`) and then applies it; other values throw + `InvalidDataException`. An explicit `/SMask` image (any filter, including `JPXDecode`; decoded + through the same `DecodeImageSamples` path without nested masks, and required to be a + single-component non-`/Indexed` image, else `InvalidDataException`) is nearest-neighbor resampled + to the base image and its luminance multiplies the base alpha in place on the base surface + (`ApplySoftMask`), taking + precedence over `/SMaskInData`; `/Mask` and `/Matte` are not consulted. Inline images (`BI`) are + not supported at all, which satisfies the PDF rule that `JPXDecode` is not permitted inline. `CompositeImageOntoSurface` inverts the CTM (a non-invertible/degenerate CTM silently paints nothing), computes the device-space bounding box of the transformed unit square, and for every destination pixel in that box nearest-neighbor-samples the source image (`row = floor((1 - v) * @@ -738,9 +778,9 @@ its own distinguishable `Feature` token. helper (standard Porter-Duff "over" alpha compositing โ€” straight/unassociated alpha in and out, `outA = fgA + bgA * (1 - fgA)`, each color channel `outC = (fgC * fgA + bgC * bgA * (1 - fgA)) / outA` when `outA != 0` else `0`, round-half-away-from-zero, clamped to `[0, 255]`) rather than - overwritten outright โ€” a documented fix (every decoded PDF image XObject is currently always - fully opaque per the `/SMask`/`/Mask` limitation noted above, so this matters only for a future - phase that decodes a non-opaque alpha channel, or for this same helper's shared reuse by + overwritten outright โ€” a documented fix (an image XObject is opaque unless it carries an `/SMask` + or a JPX `/SMaskInData` alpha channel, so this matters for those images and for this same + helper's shared reuse by `DemaConsulting.CanvasNet.Pptx`'s own `PaintPicture`, which does sample genuinely non-opaque source pixels today โ€” see that package's own design documentation). - **CCITT Group 4 fax decoding (`PdfDocument.CcittFax.cs`, added in Phase 15)** โ€” a from-scratch @@ -1551,7 +1591,8 @@ change behavior for any document within normal real-world limits. with another filter) โ€” `Codecs.UnsupportedImageFeatureException`; an unrecognized `/Predictor` value, a malformed `/Filter`/`/DecodeParms` shape, or malformed bytes for any of the five supported filters is `InvalidDataException` instead (malformed, not merely unsupported). -- **An unsupported image `/BitsPerComponent`** (anything other than `8`), or a TIFF predictor +- **An unsupported image `/BitsPerComponent`** (anything other than `1`, `2`, `4`, `8` or `16`, or + 16 on an `/Indexed` image), or a TIFF predictor combined with a non-`8` `/BitsPerComponent` โ€” `Codecs.UnsupportedImageFeatureException`. - **`CCITTFaxDecode` with a non-negative `/K` (Group 3), or with `/EndOfLine true`** โ€” `Codecs.UnsupportedImageFeatureException` (added in Phase 15; this decoder implements Group 4 diff --git a/docs/design/canvas-net-svg/svg-codec.md b/docs/design/canvas-net-svg/svg-codec.md index 575143d0..32a05542 100644 --- a/docs/design/canvas-net-svg/svg-codec.md +++ b/docs/design/canvas-net-svg/svg-codec.md @@ -1149,7 +1149,7 @@ explicitly in this switch (routing to the same out-of-scope no-op as an unrecogn specifically to document the nested-SVG scope decision at the dispatch site itself, rather than leaving it to fall through an default case indistinguishably from a genuinely unsupported format. A malformed base64 payload (`FormatException`), a raster payload the target codec's own `Load` -rejects (`InvalidDataException` - every one of the five codecs already documents this as its own +rejects (`InvalidDataException` - every one of the six codecs already documents this as its own malformed/oversized-data exception), an out-of-range decoded dimension (`ArgumentOutOfRangeException` - defense-in-depth for `Surface`'s own constructor, normally preempted by each codec's own `Surface.MaxDimension` check), or a well-formed but unsupported diff --git a/docs/design/canvas-net.md b/docs/design/canvas-net.md index 7e9ed5c4..9b9d2881 100644 --- a/docs/design/canvas-net.md +++ b/docs/design/canvas-net.md @@ -18,8 +18,10 @@ DEMA Consulting best practices. The system consists of six implemented subsystem span-based row access and independent-copy cropping) and the `Rgba32` unit (a single-pixel value type, documented inline within `Surface`). See _Canvas Subsystem Design_ (`canvas.md`). - **Codecs subsystem** (namespace `DemaConsulting.CanvasNet.Codecs`, folder - `src/DemaConsulting.CanvasNet/Codecs/`, flat โ€” no further nesting): five hand-rolled image - format codecs, each converting to and from a `DemaConsulting.CanvasNet.Canvas.Surface` pixel buffer โ€” + `src/DemaConsulting.CanvasNet/Codecs/`, flat โ€” no further nesting): six hand-rolled image + format codecs operating on a `DemaConsulting.CanvasNet.Canvas.Surface` pixel buffer (`BmpCodec`, + `PngCodec`, `TiffCodec`, and `JpegCodec` convert to and from it; `GifCodec` and `Jpeg2000Codec` + are decode-only loaders) โ€” `BmpCodec` (uncompressed 24-bit/32-bit Windows BMP), `PngCodec` (saves 8-bit-per-channel Truecolor and Truecolor-with-alpha, non-interlaced PNG; loads every non-interlaced, spec-valid PNG color type/bit depth combination), `TiffCodec` (8-bit-per-sample RGB, RGBA, and @@ -27,7 +29,9 @@ DEMA Consulting best practices. The system consists of six implemented subsystem `JpegCodec` (a common real-world subset of JPEG: baseline/progressive decode with 4:4:4/4:2:2/4:2:0 support, baseline 4:2:0 encode), and `GifCodec` (decode-only load of a GIF's first image frame, including LZW decompression and deinterlacing, plus a `GetInfo` that reports - the file's true total frame count without resolving later frames into a `Surface`). See + the file's true total frame count without resolving later frames into a `Surface`), and + `Jpeg2000Codec` (decode-only JPEG 2000 Part 1 decoder for JP2 files and raw codestreams, also + exposing the decoded color space and opacity channel for PDF `/JPXDecode` images). See _Codecs Subsystem Design_ (`codecs.md`). - **Geometry subsystem** (namespace `DemaConsulting.CanvasNet.Geometry`, folder `src/DemaConsulting.CanvasNet/Geometry/`, flat โ€” no further nesting): vector-geometry @@ -82,9 +86,9 @@ DEMA Consulting best practices. The system consists of six implemented subsystem The `Codecs` subsystem depends on the `Canvas` subsystem's `Surface` unit (constructing surfaces and reading/writing rows via `Surface.GetRowSpanBytes`); the `Canvas` subsystem has no dependency -on `Codecs` or on any other subsystem. Within the `Codecs` subsystem, its five units are flat and -mutually independent โ€” none of `BmpCodec`, `PngCodec`, `TiffCodec`, `JpegCodec`, or `GifCodec` -depends on any other codec. Each codec also exposes a pair of `GetInfo(Stream)`/`GetInfo(string)` overloads +on `Codecs` or on any other subsystem. Within the `Codecs` subsystem, its six units are flat and +mutually independent โ€” none of `BmpCodec`, `PngCodec`, `TiffCodec`, `JpegCodec`, `GifCodec`, +or `Jpeg2000Codec` depends on any other codec. Each codec also exposes a pair of `GetInfo(Stream)`/`GetInfo(string)` overloads returning the shared `ImageInfo` record struct (width, height, channel count, and alpha presence) without fully decoding pixel data and without enforcing `Surface.MaxDimension` (now a public constant, so callers can perform this comparison themselves before ever calling `Load`) โ€” except @@ -97,7 +101,8 @@ pattern, and _Surface Unit Design_ (`canvas/surface.md`) for `MaxDimension`. See _Surface Unit Design_ (`canvas/surface.md`), _BmpCodec Unit Design_ (`codecs/bmp-codec.md`), _PngCodec Unit Design_ (`codecs/png-codec.md`), _TiffCodec Unit Design_ (`codecs/tiff-codec.md`), _JpegCodec Unit Design_ -(`codecs/jpeg-codec.md`), and _GifCodec Unit Design_ (`codecs/gif-codec.md`) for each unit's +(`codecs/jpeg-codec.md`), _GifCodec Unit Design_ (`codecs/gif-codec.md`), and +_Jpeg2000Codec Unit Design_ (`codecs/jpeg2000-codec.md`) for each unit's internal collaboration. The `Geometry` subsystem's five units collaborate as follows: `PathBuilder` records raw drawing @@ -239,6 +244,17 @@ The system exposes the following public API to external consumers: `ImageInfo` record struct, additionally reporting the file's true total frame count; unlike every other codec's `GetInfo`, this decodes the first frame's LZW-compressed pixel data to validate `CanDecode` (but never resolves it into a rendered `Surface`). +- **Jpeg2000Codec.Load(Stream stream)** / **Jpeg2000Codec.Load(string path)**: Loads a `Surface` from a + JPEG 2000 (JP2 or raw codestream) stream or file (decode-only - no save support). Throws + `ArgumentNullException` for a null `stream`/`path`, `ArgumentException` for an empty `path`, + `FileNotFoundException` for a missing file, `InvalidDataException` for malformed data, and + `UnsupportedImageFeatureException` for valid but unsupported JPEG 2000 features. +- **Jpeg2000Codec.GetInfo(Stream stream)** / **Jpeg2000Codec.GetInfo(string path)**: Returns the shared + `ImageInfo` record struct from the container and SIZ header alone, without decoding pixels. +- **Jpeg2000Codec.Decode(Stream stream)** / **Jpeg2000Codec.Decode(byte[] data)**: Decodes to a + `Jpeg2000Image` exposing 8-bit color samples, color space, optional opacity samples and ICC + profile. Overloads taking a `Jpeg2000DecoderLimits` (a public record of resource limits with a + `Default` instance) apply caller-supplied limits. - **Rect(float x, float y, float width, float height)**: Constructor; an axis-aligned rectangle in position-plus-size form. `Rect.Empty` is a static, publicly readable union-identity sentinel. - **Rect.Union(Rect)** / **Rect.Union(Rect, Rect)**: Returns the smallest rectangle enclosing both @@ -354,6 +370,10 @@ The system exposes the following public API to external consumers: | `JpegCodec.Save(...)` | Inbound | Method call | `surface` non-null | | `GifCodec.Load(...)` | Inbound/Outbound | Method call / `Surface` return | Valid GIF stream or path | | `GifCodec.GetInfo(...)` | Inbound/Outbound | Method call / `ImageInfo` | Valid GIF stream or path | +| `Jpeg2000Codec.Load(...)` | Inbound/Outbound | Method call / `Surface` return | Valid JPEG 2000 stream/path | +| `Jpeg2000Codec.GetInfo(...)` | Inbound/Outbound | Method call / `ImageInfo` | Valid JPEG 2000 stream/path | +| `Jpeg2000Codec.Decode(...)` | Inbound/Outbound | Method call / `Jpeg2000Image` | Valid JPEG 2000 stream/bytes | +| `Jpeg2000DecoderLimits` | Inbound | `Decode(...)` argument | Positive limits | | `Rect.Union(...)` | Inbound/Outbound | Method call / `Rect` return | None | | `Rect.Intersect(...)` | Inbound/Outbound | Method call / `Rect` return | None | | `Rect.Transform(Matrix3x2)` | Inbound/Outbound | Method call / `Rect` return | None | @@ -382,7 +402,7 @@ CanvasNet has one runtime NuGet dependency: `System.Numerics.Tensors`, used by t unit's vectorized bulk pixel operations (`PremultiplyAlpha`, `UnpremultiplyAlpha`, `CompositeOver`) for their `TensorPrimitives`-based numeric work โ€” see _Surface Unit Design_ (`canvas/surface.md`) for details. Every other member of `Surface`, and all of `BmpCodec`, -`PngCodec`, `TiffCodec`, `JpegCodec`, and `GifCodec`, are implemented exclusively against the +`PngCodec`, `TiffCodec`, `JpegCodec`, `GifCodec`, and `Jpeg2000Codec`, are implemented exclusively against the .NET Base Class Library (`Surface`'s remaining use of `Span` and `MemoryMarshal` are BCL APIs available natively on every target framework; `BmpCodec` uses only `System.IO` types; `PngCodec` and @@ -577,6 +597,17 @@ measures (IEC 62304 ยง5.3.3). then scanning, without fully decoding, every subsequent frame's own block structure) without ever resolving those later frames into a rendered `Surface` +**JPEG 2000 load path:** + +1. **Input**: Method parameter `stream`/`path` (or `data` for `Decode`) +2. **Validation**: `ArgumentNullException` for null arguments, `ArgumentException` for an empty + `path`, `InvalidDataException` for malformed data, `UnsupportedImageFeatureException` for + unsupported features +3. **Processing**: Parses the JP2 boxes (if any) and codestream headers, decodes tier-2 packets and + tier-1 code-blocks, applies dequantization, the inverse wavelet and component transforms, and + scales samples to 8 bits +4. **Output**: A new `Surface` (`Load`) or a `Jpeg2000Image` (`Decode`) + **TrueType font load and glyph-query path:** 1. **Input**: A font stream or file path, followed by caller-supplied codepoints or glyph indices diff --git a/docs/design/canvas-net/canvas.md b/docs/design/canvas-net/canvas.md index 5cdd9d8a..4e39164e 100644 --- a/docs/design/canvas-net/canvas.md +++ b/docs/design/canvas-net/canvas.md @@ -33,7 +33,7 @@ of CanvasNet's target frameworks. The `Canvas` subsystem's `Surface` unit is a public API entry point, invoked externally by consumers of the CanvasNet package. It is also invoked internally by every unit of the `Codecs` -subsystem (`BmpCodec`, `PngCodec`, `TiffCodec`, `JpegCodec`, `GifCodec`), each of which constructs +subsystem (`BmpCodec`, `PngCodec`, `TiffCodec`, `JpegCodec`, `GifCodec`, `Jpeg2000Codec`), each of which constructs a `Surface` when loading and reads its rows when saving โ€” see _Codecs Subsystem Design_ (`codecs.md`) for details. It is also invoked internally by the `Drawing` subsystem's `PathFiller` unit, which composites each rasterized row directly via `Surface.CompositeOverSpan` โ€” see diff --git a/docs/design/canvas-net/codecs.md b/docs/design/canvas-net/codecs.md index 27734ed4..9efd5e1e 100644 --- a/docs/design/canvas-net/codecs.md +++ b/docs/design/canvas-net/codecs.md @@ -4,22 +4,24 @@ -The `Codecs` subsystem is the second software subsystem in CanvasNet. It groups five flat, -hand-rolled image-format codecs โ€” `BmpCodec`, `PngCodec`, `TiffCodec`, `JpegCodec`, and -`GifCodec`. Four raster codecs (`BmpCodec`, `PngCodec`, `TiffCodec`, and `JpegCodec`) each +The `Codecs` subsystem is the second software subsystem in CanvasNet. It groups six flat, +hand-rolled image-format codecs โ€” `BmpCodec`, `PngCodec`, `TiffCodec`, `JpegCodec`, `GifCodec`, +and `Jpeg2000Codec`. Four raster codecs (`BmpCodec`, `PngCodec`, `TiffCodec`, and `JpegCodec`) each convert to and from a `DemaConsulting.CanvasNet.Canvas.Surface` pixel buffer; `GifCodec` is -decode-only โ€” it loads a `Surface` from the first frame of a GIF file but has no `Save` method. +decode-only โ€” it loads a `Surface` from the first frame of a GIF file but has no `Save` method; +`Jpeg2000Codec` is likewise decode-only โ€” it loads a `Surface` from a JPEG 2000 (JP2 or raw +codestream) image and also exposes the decoded color space and opacity channel via `Decode`. -Note: SVG rasterization was previously modeled as a sixth unit of this subsystem, but is now -provided by the separate `CanvasNetSvg` system (its own package, `DemaConsulting.CanvasNet.Svg`) +Note: SVG rasterization is not part of this subsystem; it is provided by the separate +`CanvasNetSvg` system (its own package, `DemaConsulting.CanvasNet.Svg`) โ€” see _CanvasNetSvg System Design_ (`../canvas-net-svg.md`) and _SvgCodec Unit Design_ (`../canvas-net-svg/svg-codec.md`). ### Purpose The `Codecs` subsystem groups the software units responsible for reading and writing pixel data -in standard image file formats. It is flat: none of its five units depend on one another. -`BmpCodec`, `PngCodec`, `TiffCodec`, `JpegCodec`, and `GifCodec` depend only on the `Canvas` +in standard image file formats. It is flat: none of its six units depend on one another. +`BmpCodec`, `PngCodec`, `TiffCodec`, `JpegCodec`, `GifCodec`, and `Jpeg2000Codec` depend only on the `Canvas` subsystem's `Surface` unit for their in-memory pixel representation. ### Units @@ -36,12 +38,16 @@ subsystem's `Surface` unit for their in-memory pixel representation. - **GifCodec** โ€” hand-rolled, decode-only loader for a common real-world subset of GIF files (first frame only); `GetInfo` additionally reports the file's true total frame count; see _GifCodec Unit Design_ (`codecs/gif-codec.md`) +- **Jpeg2000Codec** โ€” hand-rolled, decode-only JPEG 2000 Part 1 decoder (JP2 and raw + codestreams); `Decode` additionally exposes the decoded color space and opacity channel, and + is used by the PDF renderer for `/JPXDecode` images; see _Jpeg2000Codec Unit Design_ + (`codecs/jpeg2000-codec.md`) ### Shared Types #### ImageInfo -`ImageInfo` is a `public readonly record struct` shared by all five codecs' `GetInfo` methods: +`ImageInfo` is a `public readonly record struct` shared by all six codecs' `GetInfo` methods: ```csharp public readonly record struct ImageInfo(int Width, int Height, int Channels, bool HasAlpha) @@ -54,8 +60,12 @@ public readonly record struct ImageInfo(int Width, int Height, int Channels, boo It reports a candidate image's declared width, height, channel count, and alpha presence without requiring the caller to decode (or even fully read) the file. It is the return type of every `{Codec}.GetInfo(Stream)` / `{Codec}.GetInfo(string)` method across `BmpCodec`, `PngCodec`, -`TiffCodec`, `JpegCodec`, and `GifCodec` (the `CanvasNetSvg` system's `SvgCodec` unit also returns -this same shared type, referencing this section rather than duplicating it). `CanDecode` and +`TiffCodec`, `JpegCodec`, `GifCodec`, and `Jpeg2000Codec` (the `CanvasNetSvg` system's `SvgCodec` unit also returns +this same shared type, referencing this section rather than duplicating it). For each format +`Channels`/`HasAlpha` report the file's declared encoding; for `Jpeg2000Codec` `Channels` is the +number of color channels the decoder would return (after any JP2 palette or channel-definition +box) plus one when an opacity channel is present, and `HasAlpha` is `true` only when the file +declares an opacity channel. `CanDecode` and `FrameCount` are both declared as `init`-only properties outside the primary constructor (rather than positional parameters) specifically to avoid changing the compiler-emitted constructor/`Deconstruct` @@ -75,9 +85,11 @@ participate in its generated value equality like every other member. ### Header-Only Probing (`GetInfo`) -Each of the five codecs, in addition to its existing `Load` method (and, for four of the five -raster codecs โ€” all but the decode-only `GifCodec` โ€” `Save`), exposes a pair of `GetInfo` -overloads: +Each of the six codecs, in addition to its existing `Load` method (and, for four of the six +raster codecs โ€” all but the decode-only `GifCodec` and `Jpeg2000Codec` โ€” `Save`), exposes a pair +of `GetInfo` +overloads (`Jpeg2000Codec.GetInfo` reads only the container and SIZ header, always reports +`CanDecode == true` and, like `GifCodec`, does not enforce `Surface.MaxDimension`): ```csharp public static ImageInfo GetInfo(Stream stream); @@ -140,7 +152,7 @@ _ImageInfo_ section above for the cross-codec invariant these fallbacks exist to never throws for an input Load would successfully decode. **Well-formed but unsupported/undecodable: `UnsupportedImageFeatureException` and -`ImageInfo.CanDecode == false`.** Investigation across all five raster codecs found exactly one +`ImageInfo.CanDecode == false`.** Investigation across all six codecs found exactly one case where a codec's `Load` refuses a file that is well-formed per its own format specification โ€” PNG's Adam7 interlacing (the other codecs conflate "unsupported" and "malformed" at `GetInfo`-time already, so this exception type is not currently thrown by them). `GifCodec` is a related, but diff --git a/docs/design/canvas-net/codecs/gif-codec.md b/docs/design/canvas-net/codecs/gif-codec.md index c550b5d2..6900f7d7 100644 --- a/docs/design/canvas-net/codecs/gif-codec.md +++ b/docs/design/canvas-net/codecs/gif-codec.md @@ -2,7 +2,7 @@ ![Codecs Structure](CodecsView.svg) -The `GifCodec` class is the sixth software unit in CanvasNet's `Codecs` subsystem. It provides +The `GifCodec` class is a software unit of CanvasNet's `Codecs` subsystem. It provides hand-rolled, decode-only loading of a common real-world subset of GIF (GIF87a/GIF89a) files into `Surface` pixel buffers. diff --git a/docs/design/canvas-net/codecs/jpeg-codec.md b/docs/design/canvas-net/codecs/jpeg-codec.md index b77bd51b..13ca4c2f 100644 --- a/docs/design/canvas-net/codecs/jpeg-codec.md +++ b/docs/design/canvas-net/codecs/jpeg-codec.md @@ -2,7 +2,7 @@ ![Codecs Structure](CodecsView.svg) -The `JpegCodec` class is the sixth software unit in CanvasNet, and depends on `Surface` exactly as +The `JpegCodec` class is a software unit of CanvasNet's `Codecs` subsystem, and depends on `Surface` exactly as `BmpCodec`, `PngCodec`, and `TiffCodec` do. It provides hand-rolled loading and saving of a common real-world subset of JPEG (ITU-T T.81 / ISO/IEC 10918-1) files to and from `Surface` pixel buffers. diff --git a/docs/design/canvas-net/codecs/jpeg2000-codec.md b/docs/design/canvas-net/codecs/jpeg2000-codec.md new file mode 100644 index 00000000..d1c0e805 --- /dev/null +++ b/docs/design/canvas-net/codecs/jpeg2000-codec.md @@ -0,0 +1,287 @@ + + +### Jpeg2000Codec + +![Codecs Structure](CodecsView.svg) + +The `Jpeg2000Codec` class is a software unit of CanvasNet's `Codecs` subsystem. It +provides a hand-rolled, decode-only implementation of JPEG 2000 Part 1 (ISO/IEC 15444-1 / ITU-T +T.800): JP2 files and raw codestreams are decoded into `Surface` pixel buffers, or into a +`Jpeg2000Image` that also exposes the decoded color space and opacity channel. + +#### Purpose + +`Jpeg2000Codec` lets callers load a JPEG 2000 image into a `Surface` and lets the PDF renderer +decode `/JPXDecode` image XObjects. It is implemented entirely against the .NET base class +library, with no third-party JPEG 2000 or imaging dependency. Like `GifCodec`, it is decode-only: +it has no `Save` method. + +The class is a `static` partial class. The public surface lives in +`Codecs/Jpeg2000Codec.cs`; the decoder is split by concern across the files in +`Codecs/Jpeg2000/`: + +| File | Responsibility | +| ----------------------------- | ------------------------------------------------------------------- | +| `Jpeg2000Codec.Jp2.cs` | JP2 box parsing (color, palette, component mapping, channel defs) | +| `Jpeg2000Codec.Codestream.cs` | Codestream markers (SIZ, COD/COC, QCD/QCC, POC, PPM/PPT, RGN, SOT) | +| `Jpeg2000Codec.Geometry.cs` | Tile, component, resolution, subband, precinct and code-block grids | +| `Jpeg2000Codec.Packets.cs` | Tier-2 packet headers, tag trees, progression orders | +| `Jpeg2000Codec.Mq.cs` | MQ arithmetic decoder | +| `Jpeg2000Codec.Probe.cs` | Incremental header reader behind `GetInfo` | +| `Jpeg2000Codec.Tier1.cs` | EBCOT tier-1 decoding (all styles, ROI), dequantization on store | +| `Jpeg2000Codec.Dwt.cs` | Inverse 5-3 and 9-7 wavelet transforms | +| `Jpeg2000Codec.Decoder.cs` | Orchestration, decode budget, inverse component transforms, scaling | +| `Jpeg2000DecoderLimits.cs` | Public resource limits applied before any allocation | + +#### Public API + +- `Load(Stream)` / `Load(string)` decode to a `Surface` (gray expanded to RGB, CMYK converted to + RGB, alpha preserved). +- `GetInfo(Stream)` / `GetInfo(string)` return an `ImageInfo` from the container and SIZ header + only, without decoding and without enforcing `Surface.MaxDimension`. They read incrementally + (only the header, never the whole stream; see the notes below). +- `Decode(Stream)` / `Decode(byte[])` (and the overloads taking a `Jpeg2000DecoderLimits`) return a + `Jpeg2000Image` (`Width`, `Height`, `ColorSpace`, `ColorChannelCount`, `ColorSamples`, + `AlphaSamples`, `HasAlpha` (true when `AlphaSamples` is present), `AlphaPremultiplied`, `IccProfile`, + `BitDepth` (source depth of the first color channel's component, before 8-bit scaling) and + `HasPalette` (the JP2 palette was applied to `ColorSamples`)) with + 8-bit interleaved samples. `Decode` enforces the resource limits below, including + `Surface.MaxDimension` on the image size. +- `Jpeg2000DecoderLimits` is a public record of resource limits (maximum input bytes, width, height, + total samples, per-tile samples, tiles, precincts, code-blocks and packets, progression-order + changes, progression steps and tier-1 work). `Jpeg2000DecoderLimits.Default` is used by every + overload that takes no limits; its dimension limit equals `Surface.MaxDimension`. Callers such as + the PDF layer may pass tighter limits. Invalid limits throw `ArgumentOutOfRangeException`. + +#### Resource Limits + +Limits are enforced during header validation, before the memory they protect is allocated: + +- SIZ: image dimensions, tile count, and the total plane size (sum over components) are checked + against the limits immediately after SIZ is parsed; the output size (width x height x channels) + is checked before the planes are allocated. +- Geometry: per-tile sample, precinct and code-block counts are computed in 64-bit arithmetic + and checked before arrays are created. The precinct count is additionally bounded by the amount + of packet data: every packet needs at least one header bit, so a tile cannot have more packets + than eight times the bytes of its tile-part data plus packed headers. A few hundred bytes of + header can therefore no longer force a large allocation. Precinct and packet counts are charged + by a single `GeometryBudget.ChargePrecincts` check that reports distinct causes: more precincts + than the limit, more packets than the limit, or more packets than the data can carry. +- Progression: one decode-wide `DecodeBudget` is charged per candidate packet and per position + step, cumulatively across tiles and POC entries. Its ceiling is the smaller of + `MaxProgressionSteps` and a base plus a per-input-byte allowance, so work scales with input size. + The number of POC entries in effect (main or tile header) is capped by `MaxProgressionChanges`, + and a POC volume empty or wholly contained in an earlier one is skipped up front. After + enumeration the number of packets actually covered must equal the tile's packet count; a + progression (including POC volumes) that leaves packets uncovered is `InvalidDataException`, + since the standard requires every packet to be covered. +- Tier-1: the same `DecodeBudget` charges entropy-decoding work in sample-passes (block samples + times coding passes). Its ceiling is `min(MaxTier1Work, 2^24 + 2^14 x input bytes)`, so the work + a stream may demand scales with its size. The per-input-byte term is what bounds hostile input: + a hostile 76 KB stream (8192 x 8192, 64 x 64 blocks, maximum passes per block) is rejected after + about 4 seconds (Release) or 10 seconds (Debug, including JIT) of CPU instead of minutes (measured manually; + no test asserts timings). The absolute ceiling + `MaxTier1Work` defaults to 2^34, which is at least `MaxTotalSamples` (2^27) times the maximum + of 88 passes per block, so no image the default sample limit admits can reach it; it only + matters for callers who raise `MaxTotalSamples` or want a hard cap. An earlier default of 2^30 + rejected valid large lossless images (a 5800 x 5800 16-bit sparse image needs about 1.5 x 10^9 + sample-passes). The 2^14 figure was measured by bisecting `MaxTier1Work` on streams from the + test encoder. The worst valid case is a flat 16-bit plane with 64 x 64 blocks, which costs + about 4,560 sample-passes per input byte regardless of size (1024 to 4096 square, zero or one + decomposition level: 4,527 to 4,563; for example 4096 x 4096 needs about 7.2 x 10^8 + sample-passes from a 158 KB stream), because every block runs all of its bit-plane passes over + almost no coded data. 2^14 leaves a margin of about 3.6; an earlier 2^12 rejected such images + (8192 x 8192 constant 16-bit, as written by OpenJPEG). Denser streams need far less (under + 100). Worst-case hostile CPU cost is therefore the allowance times the cost of a sample-pass + (about 3.4 ns in a Release build, twice that in Debug): about 55 microseconds per input byte, + so a stream of about 1 MB that reaches the 2^34 absolute ceiling costs on the order of a minute + of CPU (two or more in Debug). Callers decoding untrusted input should pass tighter + `Jpeg2000DecoderLimits` (see the user guide). A pass count is deliberately not compared with the + segment length: an MQ-coded pass can legitimately consume far less than one byte, so such a rule + would reject valid streams; the work budget is the mitigation. +- `MaxBitPlanes` (30) is the single bit-plane ceiling. Each band's bit-plane count + (guard bits + exponent - 1) is validated once during geometry construction: out-of-range values + are `InvalidDataException`; a count that only exceeds the ceiling once the ROI shift is added + is `UnsupportedImageFeatureException`. + +#### Decoding Pipeline + +1. **Container**: a JP2 signature box is parsed for the color specification (enumerated sRGB, + grayscale, sYCC, CMYK, or an ICC profile that is reported but never applied), palette and + component-mapping boxes, and channel-definition boxes (opacity, premultiplied opacity and + channel reordering). Anything else is treated as a raw codestream. +2. **Codestream headers**: SIZ, COD/COC, QCD/QCC, RGN, POC, PPM/PPT, TLM/PLM/PLT/CRG/COM are + handled or skipped; tiles may have arbitrary image and tile origins and may be split into + tile-parts; tile-part headers may override coding and quantization parameters, but only in the + first tile-part of a tile (COD/COC/QCD/QCC/RGN in a later tile-part is `InvalidDataException`; + POC and PPT are accepted in any tile-part). + Every marker-segment and JP2 box parser checks its payload length exactly and every field is + range-checked, so no byte is silently dropped and no invalid value is accepted. Every failure is + `InvalidDataException`, except valid-but-unsupported features, which are + `UnsupportedImageFeatureException`; no other exception type is thrown. The rules, with each + deliberate leniency, are: + + + + | Structure | Validation rule | Deliberate leniency | + | --- | --- | --- | + | SOC | First marker; a second SOC in the main header is rejected | None | + | SIZ | Directly after SOC, at most once, in the main header only; exactly 38 + 3 x Csiz bytes; Csiz 1 to 16384 (more than 16 is unsupported); Xsiz, Ysiz, XTsiz, YTsiz non-zero; XRsiz, YRsiz non-zero; XOsiz < Xsiz and YOsiz < Ysiz; XTOsiz and YTOsiz at most the image offset and tile origin + size beyond it; Ssiz depth above 16 is unsupported; Rsiz Part 2 and HTJ2K bits are unsupported | Other Rsiz profile values are not checked | + | COD / COC | Fixed part plus one precinct byte per resolution when precincts are declared, no trailing bytes; levels at most 32; code-block exponents 2 to 10 with xcb + ycb at most 12; precinct exponent 0 only at resolution 0; progression order at most 4; layers at least 1; style bits 6-7 and transform above 1 are unsupported; MCT above 1 is unsupported; MCT needs three equal components | Scod reserved bits 3-7 are ignored; a repeated COD replaces the earlier one (as OpenJPEG) | + | QCD / QCC | Style 0 exactly 3 x levels + 1 entries (checked once the effective levels are known); style 1 exactly one 16-bit entry; style 2 a non-zero even number of bytes; style above 2 and an empty segment are rejected; derived exponents must stay in the bit-plane range | Style 2 extra entries are ignored; guard-bit count is not range-checked beyond the bit-plane limits | + | RGN | Exactly 3 bytes; component index below Csiz; style must be 0 (else unsupported); shift at most 37 | None | + | POC | A non-zero multiple of 7 bytes; RSpoc < REpoc, CSpoc < CEpoc, LYEpoc at least 1, progression at most 4; the progressions must cover every packet; at most the entry cap | A later entry may name a larger layer end (already-sent packets are skipped) | + | SOT | Tile-part header only; Lsot exactly 10; Isot below the tile count; Psot 0 (last tile-part) or at least 14 and within the data; TPsot equals the tile-parts already seen | TNsot is advisory; Psot 1 to 13 is rejected, where OpenJPEG warns on 12 | + | SOD | Must end every tile-part header; a tile-part without SOD fails | None | + | EOC | Ends the codestream | A missing EOC, one trailing byte, and bytes after EOC are ignored | + | PPM / PPT | At least the one-byte index; PPM chunks must tile the payload exactly; PPM in the main header only, PPT in tile-part headers only; a repeated Zppm or Zppt index is rejected | A gap in the index sequence is tolerated, as in OpenJPEG; PPM and PPT may be mixed | + | TLM / PLM / CRG | Main header only (rejected in a tile-part header) | Skipped by their length field, content not interpreted | + | PLT | Tile-part header only (rejected in the main header) | Skipped by length | + | COM, unknown markers | Skipped by their length field | Content not interpreted | + | SOP | In packet data only; a SOP marker in either header is rejected; when present Lsop exactly 4 | Presence is not required even when the Scod SOP bit is set, and Nsop is not checked (see below) | + | Marker order | SIZ first; COD and QCD required in the main header; SOT before SOD; COD/COC/QCD/QCC/RGN only in the first tile-part of a tile | Marker order inside a header is otherwise free | + | JP2 signature, jP | Must be the first box with content `0D 0A 87 0A` | None | + | JP2 ftyp | Must be the second box; content a multiple of 4 and at least 8 bytes | Brand and compatibility list are not interpreted | + | JP2 jp2h | Must precede jp2c and contain an ihdr; its child boxes are read in order | Several jp2h boxes: the first of each child wins; boxes after jp2c are not examined | + | JP2 ihdr | Exactly 14 bytes; height and width must equal the SIZ image height and width | NC, BPC, C, UnkC and IPR are not compared with SIZ (OpenJPEG only warns); a second ihdr is ignored; ihdr need not be the first child | + | JP2 bpcc | Not interpreted, depths come from SIZ | Always | + | JP2 colr | At least 3 bytes; METH 1 is exactly 7 bytes except EnumCS 14 and 19, which carry extra parameters | METH 2 takes the rest of the box as an ICC profile; other METH values are ignored; a missing colr falls back to the heuristic below | + | JP2 pclr | Exactly the header, depth bytes and entries x columns values; signed columns are unsupported | Out-of-range palette indexes are clamped | + | JP2 cmap | A multiple of 4 bytes | Ignored without a pclr | + | JP2 cdef | Exactly 2 + 6 x N bytes | A repeated channel association: the last wins | + | JP2 res and unknown boxes | Skipped by their box length | Not interpreted | + | JP2 box length | Truncated or oversized boxes are rejected | A length of 0 (to end of data) or 1 (extended 64-bit length) is accepted | + | jp2c | Required, with a valid codestream | A raw codestream (no signature box) is accepted without any JP2 box | + + + + The required JP2 boxes (signature, `ftyp`, `jp2h` with an `ihdr`, `jp2c`) are the ones OpenJPEG + also insists on; `colr` is not required because OpenJPEG decodes a file without one. The same + rule applies to `Decode` and `GetInfo`. +3. **Tier-2**: packet headers are decoded (tag trees, inclusion, zero bit-planes, pass counts, + lengths) for every progression order and quality layer, with optional SOP/EPH markers. Every + tile must have at least one tile-part; a tile with none (for example a codestream cut at a tile + boundary) is `InvalidDataException`. Tile-parts of a tile must appear in order (TPsot equals + the number of tile-parts already seen for that tile); an out-of-sequence index is + `InvalidDataException`, as in OpenJPEG. TNsot (the declared tile-part count) is advisory and + not enforced, because real encoders emit wrong counts that OpenJPEG only warns about. +4. **Tier-1**: each code-block is decoded with the MQ decoder through the significance + propagation, magnitude refinement and cleanup passes, honouring selective arithmetic bypass, + reset, termination, vertically causal, predictable termination and segmentation symbols (the + decoded symbol must be 1010), and + the ROI max-shift. +5. **Reconstruction**: dequantization (done as each code-block is stored, in `Tier1.Store`), inverse + DWT (reversible 5-3 or irreversible 9-7), inverse + RCT/ICT, DC level shift, clamping and scaling to 8 bits per sample (1 to 16 bit, signed or + unsigned, subsampled components upsampled). + +#### Error Handling + +- `ArgumentNullException` for null stream, data or path; `ArgumentException` for an empty path; + `FileNotFoundException` for a missing file. +- `InvalidDataException` for data that is not JPEG 2000, dimensions above + a `Jpeg2000DecoderLimits` limit, and truncated or corrupt data. Parse sites validate counts, + lengths and indexes explicitly and throw `InvalidDataException`; resource limits are enforced as + described above, so hostile files fail quickly rather than exhausting memory or time. A narrow + internal backstop (`Guard`) converts only `IndexOutOfRangeException` and `OverflowException` + into `InvalidDataException` (keeping the original as the inner exception); it is a last resort, + and the robustness tests assert that no documented failure relies on it. +- `UnsupportedImageFeatureException` for valid but unsupported features: JPEG 2000 Part 2 + extensions, High Throughput (Part 15) codestreams, unknown transforms or ROI styles, five or + more components without a channel definition, more than sixteen components, four color channels + in a declared color space other than CMYK, and bit depths above 16. + +Four color channels are treated as CMYK only when the color space is CMYK, or when no color +specification exists at all (a raw codestream), as a documented heuristic. An sRGB image with an +extra channel and no channel definition keeps the three color channels and ignores the extra one. + +There is no recovery or partial decoding: a failure always propagates to the caller. + +#### Deliberately Lenient Behavior + +The following are tolerated rather than rejected. Each matches OpenJPEG (verified by crafting +streams and decoding them with ImageMagick) or the standard leaves the behavior to the reader: + +- TNsot (declared tile-part count) is advisory; see Decoding Pipeline. +- A repeated channel association for the same channel in the cdef box: the last one wins. +- A palette index beyond the last palette entry is clamped to the last entry. +- A JP2 signature box whose content is not `0D 0A 87 0A` is rejected as malformed, and a palette + column declared signed (bit 7 of its depth byte) is rejected as unsupported. +- An enumerated color space other than sRGB, grayscale, sYCC and CMYK is treated as RGB (or + unknown) without error; ICC profiles are reported but never applied. +- COD/COC/QCD/QCC/RGN markers are rejected in any tile-part after the first of a tile (the + tile-parts share one coding state, so a later override would apply retroactively; ISO 15444-1 + A.6.1-A.6.4); POC and PPT remain accepted in every tile-part. A mix of PPM and PPT packed + headers is accepted. +- SOP markers are not required to be present or numbered in sequence, and truncated tile data is + never decoded leniently (it is InvalidDataException). A stricter SOP rule was considered and declined: + the Scod SOP bit means markers *may* be present (T.800 A.6.1), not that they must be, and OpenJPEG + only warns. A SOP marker that is present must still have Lsop 4. +- Bytes remaining after a tile's last packet are ignored rather than rejected (matches OpenJPEG). +- POC entries from later tile-parts are appended to the tile's progression list in tile-part order, + as the standard specifies (covered by `Jpeg2000Codec_Decode_PocInLaterTilePart_RoundTripsExactly`). +- Memory: tile-part bodies (and PPM/PPT packed headers) are gathered per tile in `MemoryStream`s + while parsing. The decoder reads them in place through `ByteCursor.FromStream` + (`GetBuffer` plus the stream length), so no second copy is made, and `TileData.Release` drops + each tile's buffers as soon as its packets have been read. +- GetInfo reads incrementally and never buffers the stream: for a raw codestream it reads the SOC + and SIZ marker segments (at most 65,539 bytes); for JP2 it walks the box headers and walks the + children of `jp2h` one by one (the superbox itself is never buffered, so a hostile declared + length costs nothing): only child headers and the boxes the parsers interpret (`colr`, `pclr`, + `cmap`, `cdef`) are read, each bounded to 1 MiB (the largest valid palette is about 510 KiB, so a + larger one is `InvalidDataException`), `colr` is read for its first 256 bytes only (an ICC + profile needs just its 128-byte header to classify the color space; a METH 1 box longer than that + is malformed anyway), and `ihdr` is read for the same 14-byte and SIZ-dimension checks as `Decode`, + `ftyp` is validated and + skipped, and every other child (`bpcc`, `res`, unknown) is skipped. It stops after the SOC and SIZ + segments at the start of the `jp2c` box. + The existing box and marker parsers run on the bytes read. Boxes before `jp2c` that are not + needed are skipped by `Seek` when the stream is seekable and read and discarded in 8 KiB chunks + otherwise (so a huge skipped box is read on a non-seekable stream, but never buffered). The + default 256 MiB input limit still applies: a seekable stream longer than the limit is rejected up + front (as before) and a non-seekable stream is rejected once more than that many bytes have been + consumed. Because the rest of the stream is not read, a codestream box that is longer than the + data (a truncated file) is only detected on seekable streams; `Load` and `Decode` still buffer + the whole input and reject it. +- ICC profiles: the `colr` METH 2 payload is held as a `ReadOnlyMemory` slice of the + already-buffered input while the container is parsed, so no copy is made (and none before limits + are checked). `Jpeg2000Image.IccProfile` keeps its `byte[]?` type (the PDF project may read it) and + the one copy is made when the image is built, after the decode has succeeded. No + `Jpeg2000DecoderLimits` member was added: the profile lies inside the input, which + `MaxInputBytes` already bounds, so a cap could only reject files that are valid; a profile is + therefore never rejected or dropped for its size. `GetInfo` keeps only the leading bytes. +- A `cmap` box without a `pclr` box is ignored: component mapping only applies to a palette + (ISO 15444-1 I.5.3.5), and OpenJPEG does likewise. The reverse, a `pclr` without `cmap`, is + `InvalidDataException`. Pinned by `Jpeg2000Codec_Decode_CmapWithoutPclr_IsIgnored`. +- Marker precedence follows ITU-T T.800 A.6.2 and A.6.4: a tile-part COC beats a tile-part COD, + which beats a main COC, which beats the main COD (QCC/QCD likewise). `CloneForTile` therefore + starts each tile from the main state, and a tile COD or QCD replaces the inherited parameters of + every component, including those the main header set through a COC or QCC; a tile COC or QCC + then overrides that for its component. Without a tile COD, a main COC keeps applying to its + component. The tests pin each case using the test encoder. +- A style-0 (no quantization) QCD or QCC must carry exactly 3 x levels + 1 entries (T.800 A.6.4), + where the levels come from the effective COD or COC of the component. The marker order inside a + header is free (QCD may precede COD and COC may follow QCD), so the count is validated while the + sub-band geometry of each tile component is built, when the effective levels are known, and not + while the marker is parsed; both too many and too few entries are `InvalidDataException`. A + main-header QCD that every tile overrides is therefore never checked against the main levels. + Style 1 and 2 segments are unchanged (style 1 has one entry; style 2 extra entries are ignored). + +#### Dependencies + +`Jpeg2000Codec` depends on `Surface` (constructing the result of `Load`), on `ImageInfo` (the +result of `GetInfo`) and on `UnsupportedImageFeatureException`. It uses only the .NET base class +library (`System.IO`). No new NuGet package is introduced. + +#### Conformance Testing + +The codec is verified with a test-only JPEG 2000 encoder (`Jpeg2000TestEncoder`) that produces +streams exercising each coding feature, the ITU-T T.88 MQ coder test sequence, and robustness +suites (every truncation of a small stream, random bit flips, header byte substitutions, huge counts, hostile +boxes). No third-party JPEG 2000 corpus is checked in. + +#### Callers + +`Jpeg2000Codec` is a public API entry point for consumers of the CanvasNet package. Within the +repository it is also called by `PdfDocument` in `DemaConsulting.CanvasNet.Pdf` to decode +`/JPXDecode` image XObjects. diff --git a/docs/design/introduction.md b/docs/design/introduction.md index b5e86dd3..24b3059f 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -30,8 +30,9 @@ software items, specifically: - **Canvas (Subsystem)** โ€” Pixel-buffer primitives: the `Surface` unit (mutable, in-memory 32-bit RGBA pixel buffer with span-based row access) and the `Rgba32` unit - **Codecs (Subsystem)** โ€” Image format codecs: `BmpCodec`, `PngCodec`, `TiffCodec`, and - `JpegCodec`, each converting to and from a `Surface` pixel buffer; and `GifCodec`, a decode-only - unit that loads a `Surface` from the first frame of a GIF file + `JpegCodec`, each converting to and from a `Surface` pixel buffer; `GifCodec`, a decode-only + unit that loads a `Surface` from the first frame of a GIF file; and `Jpeg2000Codec`, a decode-only + JPEG 2000 Part 1 decoder (JP2 and raw codestreams) also used for PDF `/JPXDecode` images - **Geometry (Subsystem)** โ€” Vector-geometry primitives, distinct from the `Drawing` subsystem (which covers rasterization built on top of these primitives): the `Rect` unit (axis-aligned bounding rectangle), the `Path` unit (immutable vector path and its @@ -202,7 +203,7 @@ diagram or the prose below. CanvasNet is organized into six subsystems under the system level: the `Canvas` subsystem (the `Surface` and `Rgba32` units, namespace `DemaConsulting.CanvasNet.Canvas`), the `Codecs` -subsystem (the `BmpCodec`, `PngCodec`, `TiffCodec`, `JpegCodec`, and `GifCodec` units, +subsystem (the `BmpCodec`, `PngCodec`, `TiffCodec`, `JpegCodec`, `GifCodec`, and `Jpeg2000Codec` units, namespace `DemaConsulting.CanvasNet.Codecs`, flat โ€” no further nesting), the `Geometry` subsystem (the `Rect`, `Path`, `BezierFlattening`, and `SvgArcConverter` units, namespace @@ -292,8 +293,14 @@ src/DemaConsulting.CanvasNet/ โ”‚ โ”œโ”€โ”€ GifCodec.cs โ€” Decode-only, first-frame-only GIF loader; public API entry โ”‚ โ”‚ point, partial-class implementation continues under `Gif/` โ”‚ โ”œโ”€โ”€ Gif/ โ€” `GifCodec` partial-class implementation files (decode, LZW) +โ”‚ โ”œโ”€โ”€ Jpeg2000Codec.cs โ€” Decode-only JPEG 2000 Part 1 decoder; public API entry +โ”‚ โ”‚ point, partial-class implementation continues under `Jpeg2000/` +โ”‚ โ”œโ”€โ”€ Jpeg2000/ โ€” `Jpeg2000Codec` partial-class implementation files (JP2 boxes, +โ”‚ โ”‚ codestream, packets, MQ, tier-1, DWT, decoder) +โ”‚ โ”œโ”€โ”€ Jpeg2000Image.cs โ€” Decoded JPEG 2000 image (samples, color space, alpha) +โ”‚ โ”œโ”€โ”€ Jpeg2000ColorSpace.cs โ€” Color space reported by `Jpeg2000Image` โ”‚ โ”œโ”€โ”€ ImageInfo.cs โ€” Shared `GetInfo` return type (dimensions, channels, alpha, -โ”‚ โ”‚ `CanDecode`, `FrameCount`) common to all five codecs +โ”‚ โ”‚ `CanDecode`, `FrameCount`) common to all six codecs โ”‚ โ”œโ”€โ”€ UnsupportedImageFeatureException.cs โ€” Thrown by `Load` for a well-formed but โ”‚ โ”‚ unsupported file feature (for example PNG Adam7 โ”‚ โ”‚ interlacing), distinct from `InvalidDataException` diff --git a/docs/reqstream/canvas-net-pdf.yaml b/docs/reqstream/canvas-net-pdf.yaml index d2ddf19a..5b754db8 100644 --- a/docs/reqstream/canvas-net-pdf.yaml +++ b/docs/reqstream/canvas-net-pdf.yaml @@ -242,6 +242,15 @@ sections: position/size in the rendered output, not merely be silently skipped. children: - CanvasNetPdf-PdfDocument-ImageXObjects + - CanvasNetPdf-PdfDocument-ImageSoftMask + - CanvasNetPdf-PdfDocument-ImageSoftMaskInvalid + - CanvasNetPdf-PdfDocument-ImageDecodeArray + - CanvasNetPdf-PdfDocument-ImageDecodeArrayInvalid + - CanvasNetPdf-PdfDocument-JpxDecode + - CanvasNetPdf-PdfDocument-JpxColorSpace + - CanvasNetPdf-PdfDocument-JpxSMaskInData + - CanvasNetPdf-PdfDocument-JpxInvalidInput + - CanvasNetPdf-PdfDocument-JpxUnsupportedCombination tests: - CanvasNetPdf_SystemIntegration_PdfRender_ImageXObjectPlacement_CompositesExpectedPixels diff --git a/docs/reqstream/canvas-net-pdf/pdf-document.yaml b/docs/reqstream/canvas-net-pdf/pdf-document.yaml index cd4bec60..c334b3cf 100644 --- a/docs/reqstream/canvas-net-pdf/pdf-document.yaml +++ b/docs/reqstream/canvas-net-pdf/pdf-document.yaml @@ -838,10 +838,12 @@ sections: - id: CanvasNetPdf-PdfDocument-ImageXObjects title: >- The PdfDocument class shall implement the Do operator for a /Subtype /Image - XObject, decoding a bare /Filter /DCTDecode image via Codecs.JpegCodec.Load or - interpreting raw DeviceGray/DeviceRGB/DeviceCMYK 8-bit samples, and compositing the + XObject, decoding a bare /Filter /DCTDecode image via Codecs.JpegCodec.Load, or interpreting + raw DeviceGray/DeviceRGB/DeviceCMYK/Indexed samples of 1, 2, 4, 8 or 16 bits per + component, and compositing the decoded image onto the destination surface through the current transformation - matrix (nearest-neighbor sampling, treating every image as fully opaque, and + matrix (nearest-neighbor sampling, treating every image as fully opaque unless it + carries alpha via /SMask, and alpha-blending each sampled source pixel over the existing destination pixel via standard Porter-Duff "over" compositing rather than overwriting it outright); an unsupported /ColorSpace or an unsupported /BitsPerComponent shall throw @@ -865,9 +867,162 @@ sections: - PdfDocument_Images_DoOperator_DeviceCmykFlateDecode_PlacesExpectedPixels - PdfDocument_Images_DoOperator_DctDecodeJpeg_PlacesExpectedPixels - PdfDocument_Images_UnsupportedBitsPerComponent_ThrowsUnsupportedImageFeatureException + - PdfDocument_Images_RawOneBitGray_UnpacksSamples + - PdfDocument_Images_RawOneBitMultiRowPadding_UnpacksEachRowFromItsOwnByte + - PdfDocument_Images_RawFourBitRgb_UnpacksComponents + - PdfDocument_Images_RawTwoBitRgbTwoRows_PadsEachRow + - PdfDocument_Images_RawSixteenBit_UsesHighBytes + - PdfDocument_Images_RawFourBitIndexed_LooksUpPalette + - PdfDocument_Images_RawOneBitTruncated_ThrowsInvalidDataException + - PdfDocument_Images_IndexedSixteenBit_ThrowsUnsupportedImageFeatureException - PdfDocument_Images_UnsupportedColorSpace_ThrowsUnsupportedImageFeatureException - CompositeImageOntoSurface_SourceImageWithAlphaChannel_AlphaBlendsOntoExistingBackground + - id: CanvasNetPdf-PdfDocument-ImageSoftMask + title: >- + The PdfDocument class shall apply an explicit /SMask image of an image XObject + (whatever the base image's encoding: raw samples, DCTDecode or JPXDecode) as the + base image's alpha channel, multiplying the mask's single-component luminance + into the base alpha (resampling the mask to the base image with nearest-neighbor + sampling; the mask may use any supported bit depth - 1, 2, 4, 8 or 16 bits per + component) and ignoring the mask's own /SMask. + justification: | + Soft-masked images are the common way PDF producers store transparent raster + content (including 1-bit stencil-style and 16-bit masks); ignoring or rejecting + the mask would render the opaque base image or abort the page. + tests: + - PdfDocument_Images_SMaskOnRawImage_AppliesLuminanceAsAlpha + - PdfDocument_Images_SMaskOnDctImage_AppliesLuminanceAsAlpha + - PdfDocument_Images_Jpx_JpxSMask_AppliesLuminanceAsAlpha + - PdfDocument_Images_SMaskOneBit_AppliesBinaryAlpha + - PdfDocument_Images_SMaskSixteenBit_AppliesHighByteAsAlpha + - PdfDocument_Images_SMaskFourBitWithDecode_InvertsMask + + - id: CanvasNetPdf-PdfDocument-ImageSoftMaskInvalid + title: >- + The PdfDocument class shall throw InvalidDataException for an image XObject + /SMask that is not a stream with /Subtype /Image or whose image is not a + single-component, non-Indexed (gray) image. + justification: | + A soft mask is defined as a DeviceGray image; reading the red channel of an + arbitrary color mask would silently produce an unrelated alpha channel, so the + renderer fails closed instead. + tests: + - PdfDocument_Images_SMaskNotAStream_ThrowsInvalidDataException + - PdfDocument_Images_SMaskNotImageSubtype_ThrowsInvalidDataException + - PdfDocument_Images_SMaskNotGray_ThrowsInvalidDataException + + - id: CanvasNetPdf-PdfDocument-ImageDecodeArray + title: >- + The PdfDocument class shall apply an image XObject's /Decode array to the + samples of every encoding (raw samples of any supported bit depth including + /Indexed, CCITTFaxDecode, JPXDecode, and 1- or 3-channel DCTDecode images, + including /SMask images) before color-space conversion; a 4-component JPEG, which + the JPEG codec rejects, fails closed rather than ignoring /Decode. + justification: | + /Decode inverts or remaps samples (for example [1 0] on a mask or a negative + image); silently ignoring it for some encodings renders them inverted, and a + fail-closed result is required where it cannot be applied faithfully. + tests: + - PdfDocument_Images_RawWithDecode_InvertsSamples + - PdfDocument_Images_SMaskRawWithDecode_InvertsMask + - PdfDocument_Images_Jpx_DecodeArray_InvertsSamples + - PdfDocument_Images_Jpx_JpxSMaskWithDecode_InvertsMask + - PdfDocument_Images_DoOperator_CcittFaxWithDecode_InvertsPixels + - PdfDocument_Images_DoOperator_IndexedWithDecode_RemapsIndices + - PdfDocument_Images_DctRgbWithDecode_InvertsSamples + - PdfDocument_Images_DctGrayWithDecode_InvertsSamples + - PdfDocument_Images_DctFourChannelWithDecode_ThrowsInvalidDataException + + - id: CanvasNetPdf-PdfDocument-ImageDecodeArrayInvalid + title: >- + The PdfDocument class shall throw InvalidDataException for an image XObject + /Decode that is not an array of 2 x components numbers. + justification: | + A malformed /Decode cannot be interpreted; failing closed is safer than guessing + a mapping. + tests: + - PdfDocument_Images_RawWithWrongLengthDecode_ThrowsInvalidDataException + - PdfDocument_Images_Jpx_DecodeArrayWrongLength_ThrowsInvalidDataException + + - id: CanvasNetPdf-PdfDocument-JpxDecode + title: >- + The PdfDocument class shall decode a bare /Filter /JPXDecode image XObject via + Codecs.Jpeg2000Codec.Decode, bounded by explicit Jpeg2000DecoderLimits derived from + Surface.MaxDimension, and composite it like any other image XObject; JPXDecode is + never decoded from an inline image because inline images are not supported. + justification: | + JPEG 2000 is the standard compressed image encoding for many scanned and + photographic PDF documents; callers need those images rendered. + tests: + - PdfDocument_Images_Jpx_RealEncoderFixture_RendersCloseToSourcePixels + - PdfDocument_Images_Jpx_GrayWithoutColorSpace_UsesJp2ColorSpace + + - id: CanvasNetPdf-PdfDocument-JpxColorSpace + title: >- + The PdfDocument class shall use the JPEG 2000 data's own color space for a + JPXDecode image without /ColorSpace and otherwise honour /ColorSpace + (DeviceGray/RGB/CMYK, Indexed, ICCBased by component count). + justification: | + The PDF specification makes /ColorSpace optional for JPXDecode and, when present, + authoritative; both cases occur in real documents. + tests: + - PdfDocument_Images_Jpx_RgbWithoutColorSpace_UsesJp2ColorSpace + - PdfDocument_Images_Jpx_CmykWithoutColorSpace_UsesJp2ColorSpace + - PdfDocument_Images_Jpx_ExplicitDeviceRgb_HonorsColorSpace + - PdfDocument_Images_Jpx_ExplicitDeviceCmyk_HonorsColorSpace + - PdfDocument_Images_Jpx_IccBasedColorSpace_ResolvesByComponentCount + - PdfDocument_Images_Jpx_IndexedColorSpace_OverridesJp2ColorSpace + + - id: CanvasNetPdf-PdfDocument-JpxSMaskInData + title: >- + The PdfDocument class shall apply the JPEG 2000 codestream's opacity channel to a + JPXDecode image per /SMaskInData (0 ignores it, 1 uses it as alpha, 2 additionally + un-premultiplies the component samples before /Decode and color-space conversion), + with an explicit /SMask taking precedence. + justification: | + Un-premultiplying must happen on the component samples, because color conversion + (notably CMYK to RGB) is not linear in the premultiplied values. + tests: + - PdfDocument_Images_Jpx_SMaskInDataZero_IgnoresAlphaChannel + - PdfDocument_Images_Jpx_SMaskInDataOne_AppliesAlphaChannel + - PdfDocument_Images_Jpx_SMaskInDataTwo_UnpremultipliesColor + - PdfDocument_Images_Jpx_SMaskInDataTwoCmyk_UnpremultipliesBeforeConversion + - PdfDocument_Images_Jpx_SMaskAndSMaskInData_SMaskWins + + - id: CanvasNetPdf-PdfDocument-JpxInvalidInput + title: >- + The PdfDocument class shall throw InvalidDataException for a JPXDecode image whose + data is malformed, truncated or declares dimensions above Surface.MaxDimension, + whose /ColorSpace is /Pattern or has a component count that differs from the + decoded data, whose /SMaskInData is not 0, 1 or 2, or that is combined with another + filter. + justification: | + Malformed or hostile JPEG 2000 input must fail closed with one documented + exception type, before any oversized allocation. + tests: + - PdfDocument_Images_Jpx_GarbageData_ThrowsInvalidDataException + - PdfDocument_Images_Jpx_TruncatedData_ThrowsInvalidDataException + - PdfDocument_Images_Jpx_OversizedHeader_ThrowsInvalidDataException + - PdfDocument_Images_Jpx_PatternColorSpace_ThrowsInvalidDataException + - PdfDocument_Images_Jpx_ColorSpaceComponentMismatch_ThrowsInvalidDataException + - PdfDocument_Images_Jpx_InvalidSMaskInData_ThrowsInvalidDataException + - PdfDocument_Images_Jpx_CombinedWithAnotherFilter_ThrowsInvalidDataException + + - id: CanvasNetPdf-PdfDocument-JpxUnsupportedCombination + title: >- + The PdfDocument class shall throw UnsupportedImageFeatureException for a JPXDecode + image with a /ColorSpace override on JPEG 2000 data that carries its own palette, + with an Indexed /ColorSpace over non-8-bit samples, or with an Indexed /ColorSpace + and /SMaskInData 2. + justification: | + The decoder has already expanded palettes and scaled samples to 8 bits, so these + combinations cannot be interpreted faithfully and must not be silently mis-rendered. + tests: + - PdfDocument_Images_Jpx_ColorSpaceOverJp2Palette_ThrowsUnsupportedImageFeatureException + - PdfDocument_Images_Jpx_IndexedColorSpaceNonEightBit_ThrowsUnsupportedImageFeatureException + - PdfDocument_Images_Jpx_SMaskInDataTwoIndexed_ThrowsUnsupportedImageFeatureException + - id: CanvasNetPdf-PdfDocument-FormXObjects title: >- The PdfDocument class shall implement the Do operator for a /Subtype /Form diff --git a/docs/reqstream/canvas-net.yaml b/docs/reqstream/canvas-net.yaml index 729a77ec..1f493da4 100644 --- a/docs/reqstream/canvas-net.yaml +++ b/docs/reqstream/canvas-net.yaml @@ -962,6 +962,67 @@ sections: - GifCodec_GetInfo_ZeroWidth_ThrowsInvalidDataException - GifCodec_GetInfo_ZeroHeight_ThrowsInvalidDataException + - title: JPEG 2000 Codec API + requirements: + - id: CanvasNet-Lib-Jpeg2000Load + title: >- + The library shall load a Surface from a supported JPEG 2000 (JP2 or raw codestream) + file, from either a stream or a file path, and decode it to a Jpeg2000Image exposing + the 8-bit color samples, color space and opacity channel. + tags: [system] + justification: | + Callers need to import existing JPEG 2000 images - the format used for photographic + content in many PDF documents - as pixel buffers for further processing or display. + children: + - CanvasNet-Codecs-Jpeg2000Load + tests: + - Jpeg2000Codec_Load_Stream_ReturnsPixels + - Jpeg2000Codec_Load_Path_ReturnsPixels + - Jpeg2000Codec_Decode_ReversibleLevels_RoundTripsExactly + - CanvasNet_SystemIntegration_Jpeg2000Load_ReturnsExpectedPixel + + - id: CanvasNet-Lib-Jpeg2000GetInfo + title: >- + The library shall report a JPEG 2000 image's dimensions, channel count and alpha + presence from its headers alone, without decoding pixel data. + tags: [system] + justification: | + Callers triaging an untrusted file need to inspect its dimensions before paying the + cost of a full decode. + children: + - CanvasNet-Codecs-Jpeg2000GetInfo + tests: + - Jpeg2000Codec_GetInfo_RawCodestream_ReportsDimensionsAndChannels + - Jpeg2000Codec_GetInfo_Jp2WithAlpha_CountsAlphaChannel + + - id: CanvasNet-Lib-Jpeg2000ArgumentValidation + title: >- + The library shall reject null stream, data or path arguments to JPEG 2000 operations + with ArgumentNullException, and an empty path with ArgumentException. + tags: [system] + justification: | + Passing null or an empty path is a programming error; throwing immediately surfaces the + mistake and prevents obscure failures deeper in the call stack. + children: + - CanvasNet-Codecs-Jpeg2000ArgumentValidation + tests: + - Jpeg2000Codec_NullOrEmptyArguments_Throw + + - id: CanvasNet-Lib-Jpeg2000UnsupportedFormatValidation + title: >- + The library shall reject malformed JPEG 2000 data with InvalidDataException and + unsupported JPEG 2000 features with UnsupportedImageFeatureException. + tags: [system] + justification: | + Callers must be able to distinguish a malformed file from a valid but unsupported one, + and receive a clear error rather than incorrect pixels or resource exhaustion. + children: + - CanvasNet-Codecs-Jpeg2000UnsupportedFormatValidation + tests: + - Jpeg2000Codec_Load_NotJpeg2000_ThrowsInvalidData + - Jpeg2000Codec_Decode_EveryTruncationOfSmallStream_ThrowsInvalidData + - Jpeg2000Codec_Decode_Part2Capabilities_ThrowsUnsupported + # Note: SVG rasterization is provided by the separate CanvasNetSvg system (its own package, # DemaConsulting.CanvasNet.Svg) - see docs/reqstream/canvas-net-svg.yaml, not this system. diff --git a/docs/reqstream/canvas-net/codecs.yaml b/docs/reqstream/canvas-net/codecs.yaml index 0a1b1c87..98a62ac9 100644 --- a/docs/reqstream/canvas-net/codecs.yaml +++ b/docs/reqstream/canvas-net/codecs.yaml @@ -898,3 +898,72 @@ sections: - GifCodec_GetInfo_BadSignature_ThrowsInvalidDataException - GifCodec_GetInfo_ZeroWidth_ThrowsInvalidDataException - GifCodec_GetInfo_ZeroHeight_ThrowsInvalidDataException + + - id: CanvasNet-Codecs-Jpeg2000Load + title: >- + The Codecs subsystem shall load a Surface by decoding a JPEG 2000 JP2 file or raw + codestream (ISO/IEC 15444-1), from either a stream or a file path, and shall decode + it to a Jpeg2000Image of 8-bit component samples for callers that need the color + space and alpha channel, covering reversible and irreversible coding, tiles, layers, + progression orders, precincts, code-block styles, regions of interest, and the JP2 + color, palette and channel-definition boxes. + justification: | + Callers need to import existing JPEG 2000 images as pixel buffers for further + processing or display, since Jpeg2000Codec is decode-only (no Save); the PDF + renderer additionally needs the decoded color space and opacity channel. + children: + - CanvasNet-Codecs-Jpeg2000Codec-Load + - CanvasNet-Codecs-Jpeg2000Codec-Decode + - CanvasNet-Codecs-Jpeg2000Codec-EntropyCoding + - CanvasNet-Codecs-Jpeg2000Codec-PacketStructure + - CanvasNet-Codecs-Jpeg2000Codec-Codestream + - CanvasNet-Codecs-Jpeg2000Codec-Jp2Container + tests: + - Jpeg2000Codec_Load_Stream_ReturnsPixels + - Jpeg2000Codec_Load_Path_ReturnsPixels + - Jpeg2000Codec_Decode_ReversibleLevels_RoundTripsExactly + + - id: CanvasNet-Codecs-Jpeg2000GetInfo + title: >- + The Codecs subsystem shall report a JPEG 2000 image's dimensions, channel count and + alpha presence from its headers alone, without decoding pixel data and without + enforcing Surface.MaxDimension. + justification: | + Callers triaging an untrusted file need to inspect its dimensions before paying the + cost of a full decode. + children: + - CanvasNet-Codecs-Jpeg2000Codec-GetInfo + tests: + - Jpeg2000Codec_GetInfo_RawCodestream_ReportsDimensionsAndChannels + - Jpeg2000Codec_GetInfo_Jp2WithAlpha_CountsAlphaChannel + + - id: CanvasNet-Codecs-Jpeg2000ArgumentValidation + title: >- + The Codecs subsystem shall reject null stream, data or path arguments to JPEG 2000 + operations with ArgumentNullException, an empty path with ArgumentException, and a + missing file with FileNotFoundException. + justification: | + Passing null or an empty path to a JPEG 2000 operation is a programming error; + throwing immediately surfaces the mistake. + children: + - CanvasNet-Codecs-Jpeg2000Codec-ArgumentValidation + tests: + - Jpeg2000Codec_NullOrEmptyArguments_Throw + - Jpeg2000Codec_Load_MissingFile_ThrowsFileNotFound + + - id: CanvasNet-Codecs-Jpeg2000UnsupportedFormatValidation + title: >- + The Codecs subsystem shall reject malformed JPEG 2000 data (not JPEG 2000, + dimensions exceeding Surface.MaxDimension, truncated or corrupt data) with + InvalidDataException and unsupported JPEG 2000 features with + UnsupportedImageFeatureException, without unhandled exceptions or unbounded + resource consumption. + justification: | + Callers must be able to distinguish a malformed file from a valid but unsupported + one, and receive a clear error rather than incorrect pixels or resource exhaustion. + children: + - CanvasNet-Codecs-Jpeg2000Codec-Malformed + - CanvasNet-Codecs-Jpeg2000Codec-Unsupported + tests: + - Jpeg2000Codec_Load_NotJpeg2000_ThrowsInvalidData + - Jpeg2000Codec_Decode_Part2Capabilities_ThrowsUnsupported diff --git a/docs/reqstream/canvas-net/codecs/jpeg2000-codec.yaml b/docs/reqstream/canvas-net/codecs/jpeg2000-codec.yaml new file mode 100644 index 00000000..584ba1e7 --- /dev/null +++ b/docs/reqstream/canvas-net/codecs/jpeg2000-codec.yaml @@ -0,0 +1,302 @@ +--- +# Software Unit Requirements for Jpeg2000Codec +# +# These requirements describe the design-level behavior of the Jpeg2000Codec unit. + +sections: + - title: CanvasNet Requirements + sections: + - title: Codecs Requirements + sections: + - title: Jpeg2000Codec Requirements + requirements: + - id: CanvasNet-Codecs-Jpeg2000Codec-Load + title: >- + The Jpeg2000Codec class shall load a Surface from a JPEG 2000 JP2 file or raw + codestream, supplied as a stream or a file path, converting the decoded + component samples to 8-bit RGBA pixels (gray expanded to RGB, CMYK converted to + RGB, an opacity channel mapped to alpha). + justification: | + Callers need to import existing JPEG 2000 images as pixel buffers for further + processing or display using the same Surface-based API as every other codec. + tests: + - Jpeg2000Codec_Load_Stream_ReturnsPixels + - Jpeg2000Codec_Load_Grey_ExpandsToRgb + - Jpeg2000Codec_Load_Alpha_PopulatesAlpha + - Jpeg2000Codec_Load_Path_ReturnsPixels + - Jpeg2000Codec_Load_RealEncoderLosslessFixture_MatchesSourcePixelsExactly + - Jpeg2000Codec_Load_RealEncoderLossyFixture_HasAcceptablePsnr + + - id: CanvasNet-Codecs-Jpeg2000Codec-Decode + title: >- + The Jpeg2000Codec class shall decode a JPEG 2000 image to a Jpeg2000Image whose + color samples are scaled to 8 bits per sample (1 to 16 bit, signed or + unsigned, and subsampled components), reconstructing exactly for reversible + (5/3 integer wavelet, optional reversible color transform) coding and within + quantization tolerance for irreversible (9/7 wavelet, optional irreversible + color transform) coding, including all quantization styles. + justification: | + The decoded samples must match what the encoder wrote; reversible coding is + lossless and so is verified bit-exactly, and irreversible coding is verified + against a tolerance derived from its quantization step. + tests: + - Jpeg2000Codec_Decode_ReversibleNoDecomposition_RoundTripsExactly + - Jpeg2000Codec_Decode_ReversibleLevels_RoundTripsExactly + - Jpeg2000Codec_Decode_ReversibleOddSizes_RoundTripsExactly + - Jpeg2000Codec_Decode_ReversibleRct_RoundTripsExactly + - Jpeg2000Codec_Decode_FourComponents_RoundTripsExactly + - Jpeg2000Codec_Decode_DepthAndSign_ScalesToEightBits + - Jpeg2000Codec_Decode_SignedRct_RoundTripsExactly + - Jpeg2000Codec_Decode_Irreversible_WithinTolerance + - Jpeg2000Codec_Decode_IrreversibleIctMantissa_WithinTolerance + - Jpeg2000Codec_Decode_IrreversibleDerivedQuantization_WithinTolerance + - Jpeg2000Codec_Decode_IrreversibleTwelveBit_WithinTolerance + - Jpeg2000Codec_Decode_SubsampledComponents_UpsamplesToImageGrid + - Jpeg2000Codec_Decode_PerComponentLevels_RoundTripsExactly + - Jpeg2000Codec_Decode_PerComponentLevelsIrreversible_WithinTolerance + - Jpeg2000Codec_Decode_RealEncoderFixtures_ReportChannelLayout + + - id: CanvasNet-Codecs-Jpeg2000Codec-EntropyCoding + title: >- + The Jpeg2000Codec class shall decode EBCOT tier-1 code-blocks using the MQ + arithmetic decoder (verified against the ITU-T T.88 test sequence), every + code-block size, and every code-block style (selective arithmetic bypass, + context reset, termination on each pass, vertically causal context, predictable + termination, segmentation symbols), alone and combined with multiple layers. + justification: | + Tier-1 decoding is the core of JPEG 2000; each code-block style changes the + bitstream structure and must be decoded exactly. + tests: + - Jpeg2000Codec_MqEncoder_T88TestSequence_MatchesReferenceCodeStream + - Jpeg2000Codec_MqDecoder_T88ReferenceCodeStream_RecoversTestSequence + - Jpeg2000Codec_Decode_CodeBlockSizes_RoundTripsExactly + - Jpeg2000Codec_Decode_CodeBlockStyle_RoundTripsExactly + - Jpeg2000Codec_Decode_CodeBlockStyleWithLayers_RoundTripsExactly + - Jpeg2000Codec_Decode_IrreversibleBypassTermall_WithinTolerance + + - id: CanvasNet-Codecs-Jpeg2000Codec-PacketStructure + title: >- + The Jpeg2000Codec class shall decode tier-2 packet structure for every + progression order (LRCP, RLCP, RPCL, PCRL, CPRL, including with image and tile + offsets), multiple quality layers, default and per-resolution precinct sizes, + and progression order changes (POC) declared in the main or tile-part header. + justification: | + Real-world files use all five progression orders, layers and precincts; the + packet iteration order determines which bytes belong to which code-block. + tests: + - Jpeg2000Codec_Decode_Precincts_RoundTripsExactly + - Jpeg2000Codec_Decode_PerResolutionPrecincts_RoundTripsExactly + - Jpeg2000Codec_Decode_Layers_RoundTripsExactly + - Jpeg2000Codec_Decode_ProgressionOrders_RoundTripsExactly + - Jpeg2000Codec_Decode_PositionProgressionWithOffsets_RoundTripsExactly + - Jpeg2000Codec_Decode_PocMainHeader_RoundTripsExactly + - Jpeg2000Codec_Decode_PocMainHeaderWithTiles_RoundTripsExactly + - Jpeg2000Codec_Decode_PocTileHeader_RoundTripsExactly + - Jpeg2000Codec_Decode_PocTileOverridesMain_RoundTripsExactly + - Jpeg2000Codec_Load_RealEncoderLosslessFixture_MatchesSourcePixelsExactly + + - id: CanvasNet-Codecs-Jpeg2000Codec-Codestream + title: >- + The Jpeg2000Codec class shall parse the codestream main and tile-part headers: + multiple tiles with arbitrary image and tile origins, tile-parts, tile-header + overrides of coding and quantization parameters, packed packet headers (PPM and + PPT), SOP and EPH markers, region-of-interest max-shift coding (RGN), ignorable + informational markers, tile-part ordering (TPsot, with TNsot advisory), and a missing + end-of-codestream + marker with zero tile-part + length. + justification: | + Producers split and annotate codestreams in many legal ways; a decoder must + accept all of them and produce identical pixels. + tests: + - Jpeg2000Codec_Decode_Tiles_RoundTripsExactly + - Jpeg2000Codec_Decode_TileAndImageOffsets_RoundTripsExactly + - Jpeg2000Codec_Decode_TileHeaderOverrides_RoundTripsExactly + - Jpeg2000Codec_Decode_TileCodOverridesMainCoc_ForAllComponents + - Jpeg2000Codec_Decode_TileCocOverridesTileCod_ForItsComponent + - Jpeg2000Codec_Decode_NoTileCod_MainCocStillApplies + - Jpeg2000Codec_Decode_TilePrecedenceIrreversible_WithinTolerance + - Jpeg2000Codec_Decode_SopEph_RoundTripsExactly + - Jpeg2000Codec_Decode_Ppm_RoundTripsExactly + - Jpeg2000Codec_Decode_Ppt_RoundTripsExactly + - Jpeg2000Codec_Decode_TileParts_RoundTripsExactly + - Jpeg2000Codec_Decode_ExtraMarkers_AreSkipped + - Jpeg2000Codec_Decode_ZeroPsotMissingEoc_RoundTripsExactly + - Jpeg2000Codec_Decode_TilePartIndexes_AreCheckedAndCountIsAdvisory + - Jpeg2000Codec_Decode_ParamMarkersInLaterTilePart_ThrowsInvalidData + - Jpeg2000Codec_Decode_PocInLaterTilePart_RoundTripsExactly + - Jpeg2000Codec_Decode_RoiMaxShift_RoundTripsExactly + - Jpeg2000Codec_Decode_RoiOnSubsetOfComponents_RoundTripsExactly + - Jpeg2000Codec_Decode_RoiIrreversible_WithinTolerance + + - id: CanvasNet-Codecs-Jpeg2000Codec-Jp2Container + title: >- + The Jpeg2000Codec class shall read the JP2 container boxes needed to interpret + an image: the color specification box (enumerated sRGB, grayscale, sYCC + converted to sRGB, CMYK, and an ICC profile reported but never applied), the + palette and component-mapping boxes (expanded to color channels), the channel + definition box (opacity channels, premultiplication flag and channel + reordering), and every box length form (explicit, extended and to-end-of-file). + justification: | + The JP2 container decides how many color channels an image has, what they mean + and whether it carries transparency; callers such as the PDF renderer depend on + this metadata. + tests: + - Jpeg2000Codec_Decode_Jp2Srgb_ReportsColorSpace + - Jpeg2000Codec_Decode_Jp2EnumeratedColorSpace_ReportsColorSpace + - Jpeg2000Codec_Decode_Jp2Sycc_ConvertsToSrgb + - Jpeg2000Codec_Decode_Jp2IccProfile_IsReported + - Jpeg2000Codec_Decode_Jp2BoxLengthForms_AreHandled + - Jpeg2000Codec_Decode_Jp2Palette_MapsIndicesToColors + - Jpeg2000Codec_Decode_Jp2PaletteMixedDirectChannel_Maps + - Jpeg2000Codec_Decode_Jp2BadSignatureContent_ThrowsInvalidData + - Jpeg2000Codec_Decode_Jp2SignedPaletteColumn_ThrowsUnsupported + - Jpeg2000Codec_Decode_BitDepthAndHasPalette_ReportSourceFormat + - Jpeg2000Codec_Decode_Jp2Cdef_ProducesAlpha + - Jpeg2000Codec_Decode_Jp2CdefReorder_SwapsChannels + - Jpeg2000Codec_Decode_TwoComponentsWithoutCdef_DecodesAsGrey + - Jpeg2000Codec_Decode_CmapWithoutPclr_IsIgnored + + - id: CanvasNet-Codecs-Jpeg2000Codec-GetInfo + title: >- + The Jpeg2000Codec class shall report an image's width, height, channel count + and alpha presence from the container and SIZ header alone, without decoding + any pixel data and without enforcing Surface.MaxDimension, from a stream or a + file path. + justification: | + Callers triaging an untrusted file need to inspect its dimensions before + paying the cost of a full decode. + tests: + - Jpeg2000Codec_GetInfo_RawCodestream_ReportsDimensionsAndChannels + - Jpeg2000Codec_GetInfo_Jp2WithAlpha_CountsAlphaChannel + - Jpeg2000Codec_GetInfo_ImageOffset_ReportsWidthMinusOffset + - Jpeg2000Codec_GetInfo_RealEncoderFixtures_ReportDimensions + - Jpeg2000Codec_GetInfo_RawCodestream_ReadsOnlyHeaderPrefix + - Jpeg2000Codec_GetInfo_Jp2_ReadsOnlyHeaderPrefix + - Jpeg2000Codec_GetInfo_LargeSkippedBox_SeekOrDiscard + - Jpeg2000Codec_GetInfo_LargeIccProfile_IsNotBuffered + - Jpeg2000Codec_GetInfo_LargeBoxInsideJp2h_IsNotBuffered + - Jpeg2000Codec_GetInfo_HostileJp2hLength_ThrowsInvalidDataWithBoundedReads + - Jpeg2000Codec_GetInfo_OversizedPaletteBox_ThrowsInvalidData + - Jpeg2000Codec_GetInfo_TruncatedBox_ThrowsInvalidData + - Jpeg2000Codec_GetInfo_SeekableAboveInputCap_ThrowsInvalidData + + - id: CanvasNet-Codecs-Jpeg2000Codec-ArgumentValidation + title: >- + The Jpeg2000Codec class shall reject null stream, data or path arguments with + ArgumentNullException, an empty path with ArgumentException, and a missing file + with FileNotFoundException. + justification: | + Passing null or an empty path is a programming error that should be surfaced + immediately with a distinguishable exception. + tests: + - Jpeg2000Codec_NullOrEmptyArguments_Throw + - Jpeg2000Codec_Load_MissingFile_ThrowsFileNotFound + + - id: CanvasNet-Codecs-Jpeg2000Codec-Malformed + title: >- + The Jpeg2000Codec class shall reject data that is not JPEG 2000, an image whose + dimensions or sizes exceed the decoder limits (Jpeg2000DecoderLimits, with + Surface.MaxDimension as the default dimension limit), a codestream with a tile + that has no tile-part, out-of-sequence tile-parts, a progression that leaves packets uncovered, + work exceeding + the input-scaled entropy-decoding budget, and truncated, corrupted or otherwise + malformed data, with InvalidDataException, never an unhandled exception, + excessive allocation, or unbounded work. + justification: | + JPEG 2000 files frequently come from untrusted sources; every size, count and + offset in the file is attacker controlled, so each must be validated and each + resource bounded before it is used. + tests: + - Jpeg2000Codec_Load_NotJpeg2000_ThrowsInvalidData + - Jpeg2000Codec_Load_ExceedsMaxDimension_ThrowsInvalidData + - Jpeg2000Codec_Decode_RealEncoderFixtureTruncated_FailsClosed + - Jpeg2000Codec_Decode_EveryTruncationOfSmallStream_ThrowsInvalidData + - Jpeg2000Codec_Decode_TruncationsOfLargeStreams_ThrowsInvalidData + - Jpeg2000Codec_Decode_RandomBitFlips_FailCleanly + - Jpeg2000Codec_Decode_HeaderByteSubstitutions_FailCleanly + - Jpeg2000Codec_Decode_ImageBeyondDimensionLimit_ThrowsInvalidData + - Jpeg2000Codec_Decode_TooManyTiles_ThrowsInvalidData + - Jpeg2000Codec_Decode_HostileSizTileGrid_ThrowsInvalidData + - Jpeg2000Codec_Decode_TooManyCodeBlocks_ThrowsInvalidData + - Jpeg2000Codec_Decode_TooManyPrecincts_ThrowsInvalidData + - Jpeg2000Codec_Decode_TooManyPackets_ThrowsInvalidData + - Jpeg2000Codec_Decode_BadMarkerLengths_ThrowsInvalidData + - Jpeg2000Codec_Decode_BadTilePartLengths_ThrowsInvalidData + - Jpeg2000Codec_Decode_GarbageTileData_FailsCleanly + - Jpeg2000Codec_Decode_HostileBoxes_FailCleanly + - Jpeg2000Codec_Decode_HostilePoc_FailsCleanly + - Jpeg2000Codec_Decode_MoreProgressionChangesThanCap_ThrowsInvalidData + - Jpeg2000Codec_Decode_OverlappingProgressionVolumes_ThrowsInvalidData + - Jpeg2000Codec_Decode_TightProgressionLimit_ThrowsInvalidData + - Jpeg2000Codec_Decode_PocLeavingPacketsUncovered_ThrowsInvalidData + - Jpeg2000Codec_Decode_PocLaterVolumeLargerLayerEnd_SkipsSentPackets + - Jpeg2000Codec_Decode_PocTileHeaderLaterVolumeLargerLayerEnd_SkipsSentPackets + - Jpeg2000Codec_Decode_RepeatedPocEntriesWithinCap_DecodesExactly + - Jpeg2000Codec_DecodeBudget_IsCumulativeAndScalesWithInput + - Jpeg2000Codec_Decode_TightTier1Limit_ThrowsInvalidData + - Jpeg2000Codec_Decode_HostileTier1Work_ThrowsInvalidDataWhenOverBudget + - Jpeg2000Codec_Decode_HostileMaximumImageTier1Work_ExceedsDefaultBudget + - Jpeg2000Codec_Decode_SmallHostileShape_Decodes + - Jpeg2000Codec_Decode_LargeLosslessFlatImage_DecodesUnderDefaultLimits + - Jpeg2000Codec_Decode_TilePartIndexes_AreCheckedAndCountIsAdvisory + - Jpeg2000Codec_Decode_MissingTile_ThrowsInvalidData + - Jpeg2000Codec_Decode_CustomLimits_AreEnforced + - Jpeg2000Codec_Decode_InvalidLimits_ThrowsArgumentOutOfRange + - Jpeg2000Codec_Decode_HostileExponents_ThrowsInvalidData + - Jpeg2000Codec_Decode_OddLengthExpoundedQcd_ThrowsInvalidData + - Jpeg2000Codec_Decode_OddLengthExpoundedQcc_ThrowsInvalidData + - Jpeg2000Codec_Decode_DerivedQcdTrailingByte_ThrowsInvalidData + - Jpeg2000Codec_Decode_MainQuantEntryCountMismatch_ThrowsInvalidData + - Jpeg2000Codec_Decode_TileQuantEntryCountMismatch_ThrowsInvalidData + - Jpeg2000Codec_Decode_QuantEntryCountMatches_RoundTripsExactly + - Jpeg2000Codec_Decode_QcdBeforeCod_ValidatedAgainstLaterLevels + - Jpeg2000Codec_Decode_CodestreamSegmentLengthMismatch_ThrowsInvalidData + - Jpeg2000Codec_Decode_SopWrongLength_ThrowsInvalidData + - Jpeg2000Codec_Decode_Jp2BoxLengthMismatch_ThrowsInvalidData + - Jpeg2000Codec_Decode_WrongSegmentationSymbol_ThrowsInvalidData + - Jpeg2000Codec_Decode_SegmentationSymbols_RoundTripsExactly + - Jpeg2000Codec_Jp2MissingFileType_IsRejected + - Jpeg2000Codec_Jp2FileTypeNotSecond_IsRejected + - Jpeg2000Codec_Jp2FileTypeLength_IsChecked + - Jpeg2000Codec_Jp2MissingHeaderBox_IsRejected + - Jpeg2000Codec_Jp2HeaderAfterCodestream_IsRejected + - Jpeg2000Codec_Jp2HeaderWithoutIhdr_IsRejected + - Jpeg2000Codec_Jp2IhdrLength_IsChecked + - Jpeg2000Codec_Jp2IhdrSizeMismatch_IsRejected + - Jpeg2000Codec_Jp2Leniency_IsAccepted + - Jpeg2000Codec_Jp2Baseline_IsAccepted + - Jpeg2000Codec_Decode_MarkerNotAllowedInMainHeader_IsRejected + - Jpeg2000Codec_Decode_SkippedMarkerInMainHeader_IsAccepted + - Jpeg2000Codec_Decode_MarkerNotAllowedInTilePartHeader_IsRejected + - Jpeg2000Codec_Decode_SkippedMarkerInTilePartHeader_IsAccepted + - Jpeg2000Codec_Decode_MarkerOrdering_IsEnforced + - Jpeg2000Codec_Decode_MissingSod_IsRejected + - Jpeg2000Codec_Decode_BytesAfterEoc_AreIgnored + - Jpeg2000Codec_Decode_PpmIndexes_RepeatRejectedGapAccepted + - Jpeg2000Codec_Decode_PptIndexes_RepeatRejectedGapAccepted + - Jpeg2000Codec_Decode_InvalidFieldValue_IsRejected + - Jpeg2000Codec_Decode_MaximumCodeBlockArea_IsAccepted + + - id: CanvasNet-Codecs-Jpeg2000Codec-Unsupported + title: >- + The Jpeg2000Codec class shall reject valid JPEG 2000 features it does not + support (Part 2 extensions, high-throughput JPEG 2000, unknown transforms or + ROI styles, more than 16 components or bit planes, more than 16 bits per sample, four color + channels in a color space other than CMYK, and images with more than four color + channels plus alpha) with UnsupportedImageFeatureException. + justification: | + Callers must be able to distinguish a legitimate but unsupported file from a + corrupt one. + tests: + - Jpeg2000Codec_Decode_Part2Capabilities_ThrowsUnsupported + - Jpeg2000Codec_Decode_HighThroughput_ThrowsUnsupported + - Jpeg2000Codec_Decode_UnknownTransforms_ThrowsUnsupported + - Jpeg2000Codec_Decode_UnknownRoiStyle_ThrowsUnsupported + - Jpeg2000Codec_Decode_TooManyComponents_ThrowsUnsupported + - Jpeg2000Codec_Decode_RoiShiftBeyondMaxBitPlanes_ThrowsUnsupported + - Jpeg2000Codec_Decode_FourChannelsInUnknownColorSpace_ThrowsUnsupported + - Jpeg2000Codec_Decode_SrgbWithExtraChannelWithoutCdef_IgnoresExtraChannel + - Jpeg2000Codec_Decode_RawFourComponents_IsCmyk + - Jpeg2000Codec_Decode_FiveComponentsWithoutCdef_ThrowsUnsupported + - Jpeg2000Codec_Decode_DepthAbove16_ThrowsUnsupported diff --git a/docs/sysml2/model/canvas-net-pdf.sysml b/docs/sysml2/model/canvas-net-pdf.sysml index 0af5a02e..1dde68a7 100644 --- a/docs/sysml2/model/canvas-net-pdf.sysml +++ b/docs/sysml2/model/canvas-net-pdf.sysml @@ -6,7 +6,7 @@ package CanvasNet { comment designRef /* Design: docs/design/canvas-net-pdf.md */ comment verificationRef /* Verification: docs/verification/canvas-net-pdf.md */ comment reqRef /* Requirements: docs/reqstream/canvas-net-pdf.yaml */ - comment /* CanvasNetPdf depends on the sibling CanvasNet system's Canvas subsystem (for the Surface pixel buffer Render returns and paints onto), Codecs subsystem (for the shared Codecs.UnsupportedImageFeatureException type used for /Encrypt detection and every other fail-closed case), Geometry subsystem (for PathBuilder/Path, accumulating each content stream's current path, including every painted glyph outline), Drawing subsystem (for PathFiller/PathStroker/StrokeStyle, rasterizing each finished path onto the destination Surface), and, as of Phase 4, Fonts subsystem (for TrueTypeFont, loading each embedded /FontFile2 byte stream and resolving each shown codepoint to a glyph index/outline/advance width) - modeled below as non-owning `dependency` relationships to those existing subsystem part defs (not ordinary `part` usages, which would incorrectly model them as owned children of CanvasNetPdfSystem), since both systems live in this same git repository (though each is distributed as its own separate NuGet package - CanvasNetPdf is not folded into the CanvasNet package) rather than being third-party (OTS) or Shared Package dependencies (both of which are reserved for a package produced by a different repository). The Geometry/Drawing dependencies were added as of Phase 2, at the point they were actually first used by the content-stream interpreter; the Fonts dependency is new as of Phase 4, added at the point it is actually first used by the text-rendering operators. Encrypted documents using a supported security handler/scheme - the standard security handler's RC4 (/V 1/2), AES-128 (/V 4/AESV2), or AES-256 with the R5 or R6 hardened-hash key derivation (/V 5/R 5 or R 6/AESV3) - are decrypted and rendered successfully once authenticated with the correct (or, where permitted, empty/missing) user or owner password; fail-closed Codecs.UnsupportedImageFeatureException behavior applies only to unsupported encryption shapes (a non-/Standard filter, a crypt filter other than /StdCF, an /R other than 5 or 6, or any other unsupported /V/R combination) or to an incorrect or missing required password. */ + comment /* CanvasNetPdf depends on the sibling CanvasNet system's Canvas subsystem (for the Surface pixel buffer Render returns and paints onto), Codecs subsystem (for JpegCodec and Jpeg2000Codec, which decode DCTDecode and JPXDecode image XObjects, and for the shared Codecs.UnsupportedImageFeatureException type used for /Encrypt detection and every other fail-closed case), Geometry subsystem (for PathBuilder/Path, accumulating each content stream's current path, including every painted glyph outline), Drawing subsystem (for PathFiller/PathStroker/StrokeStyle, rasterizing each finished path onto the destination Surface), and, as of Phase 4, Fonts subsystem (for TrueTypeFont, loading each embedded /FontFile2 byte stream and resolving each shown codepoint to a glyph index/outline/advance width) - modeled below as non-owning `dependency` relationships to those existing subsystem part defs (not ordinary `part` usages, which would incorrectly model them as owned children of CanvasNetPdfSystem), since both systems live in this same git repository (though each is distributed as its own separate NuGet package - CanvasNetPdf is not folded into the CanvasNet package) rather than being third-party (OTS) or Shared Package dependencies (both of which are reserved for a package produced by a different repository). The Geometry/Drawing dependencies were added as of Phase 2, at the point they were actually first used by the content-stream interpreter; the Fonts dependency is new as of Phase 4, added at the point it is actually first used by the text-rendering operators. Encrypted documents using a supported security handler/scheme - the standard security handler's RC4 (/V 1/2), AES-128 (/V 4/AESV2), or AES-256 with the R5 or R6 hardened-hash key derivation (/V 5/R 5 or R 6/AESV3) - are decrypted and rendered successfully once authenticated with the correct (or, where permitted, empty/missing) user or owner password; fail-closed Codecs.UnsupportedImageFeatureException behavior applies only to unsupported encryption shapes (a non-/Standard filter, a crypt filter other than /StdCF, an /R other than 5 or 6, or any other unsupported /V/R combination) or to an incorrect or missing required password. */ part pdfDocument : PdfDocument; diff --git a/docs/sysml2/model/canvas-net-pdf/pdf-document.sysml b/docs/sysml2/model/canvas-net-pdf/pdf-document.sysml index 96ca4b35..c0f31098 100644 --- a/docs/sysml2/model/canvas-net-pdf/pdf-document.sysml +++ b/docs/sysml2/model/canvas-net-pdf/pdf-document.sysml @@ -1,9 +1,9 @@ package CanvasNet { part def PdfDocument { - doc /* PDF document parser and page-rendering codec: parses cross-references, trailer, and page tree, reports page count/size/rotation, and interprets each page's content stream (path construction/painting/clipping/shading, graphics-state stack, device color operators, generalized stream filter pipeline, image XObjects, and (Phase 4) simple TrueType font resolution/encoding and text-object/positioning/showing operators, with (Phase 6) automatic system/bundled-Liberation font-fallback substitution for a non-embedded font) to render real path geometry, images, and glyph outlines onto the returned Surface in real device color. */ + doc /* PDF document parser and page-rendering codec: parses cross-references, trailer, and page tree, reports page count/size/rotation, and interprets each page's content stream (path construction/painting/clipping/shading, graphics-state stack, device color operators, generalized stream filter pipeline, image XObjects (raw, DCTDecode, CCITTFaxDecode and JPXDecode samples, /Decode, /SMask soft masks and JPX /SMaskInData), and (Phase 4) simple TrueType font resolution/encoding and text-object/positioning/showing operators, with (Phase 6) automatic system/bundled-Liberation font-fallback substitution for a non-embedded font) to render real path geometry, images, and glyph outlines onto the returned Surface in real device color. */ comment sourceRef /* Source: src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Tokenizer.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ObjectModel.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Xref.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PageTree.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.ContentStream.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.GraphicsState.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.PathOps.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Color.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.Lzw.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.Ascii.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.RunLength.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.ToUnicode.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type0.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type1.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Fonts.Type3.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.FontFallback.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Text.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.CcittFax.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Encryption.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Functions.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Shading.Mesh.cs, src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Patterns.Tiling.cs, src/DemaConsulting.CanvasNet.Pdf/PdfPageInfo.cs, src/DemaConsulting.CanvasNet.Pdf/PdfRenderOptions.cs, src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs */ - comment testRef /* Test: test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs, test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs, test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtureTests.cs, test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs, test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentMeshShadingTests.cs, test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentSymbolicEncodingTests.cs */ + comment testRef /* Test: test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs, test/DemaConsulting.CanvasNet.Pdf.Tests/PdfSystemIntegrationTests.cs, test/DemaConsulting.CanvasNet.Pdf.Tests/PdfFixtureTests.cs, test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentEncryptionTests.cs, test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentMeshShadingTests.cs, test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentSymbolicEncodingTests.cs, test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentJpxTests.cs */ comment designRef /* Design: docs/design/canvas-net-pdf/pdf-document.md */ comment verificationRef /* Verification: docs/verification/canvas-net-pdf/pdf-document.md */ comment reqRef /* Requirements: docs/reqstream/canvas-net-pdf/pdf-document.yaml */ diff --git a/docs/sysml2/model/canvas-net/codecs.sysml b/docs/sysml2/model/canvas-net/codecs.sysml index dbbaadb2..aa37e205 100644 --- a/docs/sysml2/model/canvas-net/codecs.sysml +++ b/docs/sysml2/model/canvas-net/codecs.sysml @@ -1,6 +1,6 @@ package CanvasNet { part def Codecs { - doc /* Flat image-format codecs subsystem: BmpCodec, PngCodec, TiffCodec, and JpegCodec, each converting to and from a Canvas subsystem Surface pixel buffer, plus GifCodec, a decode-only unit that decodes only the first frame of a GIF. */ + doc /* Flat image-format codecs subsystem: BmpCodec, PngCodec, TiffCodec, and JpegCodec, each converting to and from a Canvas subsystem Surface pixel buffer, plus GifCodec, a decode-only unit that decodes only the first frame of a GIF, and Jpeg2000Codec, a decode-only unit for JPEG 2000 (JP2 and raw codestream) images. */ comment designRef /* Design: docs/design/canvas-net/codecs.md */ comment verificationRef /* Verification: docs/verification/canvas-net/codecs.md */ @@ -11,5 +11,6 @@ package CanvasNet { part tiffCodec : TiffCodec; part jpegCodec : JpegCodec; part gifCodec : GifCodec; + part jpeg2000Codec : Jpeg2000Codec; } } diff --git a/docs/sysml2/model/canvas-net/codecs/jpeg2000-codec.sysml b/docs/sysml2/model/canvas-net/codecs/jpeg2000-codec.sysml new file mode 100644 index 00000000..4c06c3be --- /dev/null +++ b/docs/sysml2/model/canvas-net/codecs/jpeg2000-codec.sysml @@ -0,0 +1,11 @@ +package CanvasNet { + part def Jpeg2000Codec { + doc /* Hand-rolled, decode-only, dependency-free ISO/IEC 15444-1 (JPEG 2000 Part 1) decoder for JP2 files and raw codestreams, converting to Surface pixel buffers or to a Jpeg2000Image of 8-bit component samples. */ + + comment sourceRef /* Source: src/DemaConsulting.CanvasNet/Codecs/Jpeg2000Codec.cs, src/DemaConsulting.CanvasNet/Codecs/Jpeg2000Image.cs, src/DemaConsulting.CanvasNet/Codecs/Jpeg2000DecoderLimits.cs, src/DemaConsulting.CanvasNet/Codecs/Jpeg2000ColorSpace.cs, src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Codestream.cs, src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Geometry.cs, src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Packets.cs, src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Mq.cs, src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Tier1.cs, src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Dwt.cs, src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Decoder.cs, src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Jp2.cs, src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Probe.cs, src/DemaConsulting.CanvasNet/Codecs/ImageInfo.cs */ + comment testRef /* Test: test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000CodecTests.cs, test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000ValidationTests.cs, test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000FixtureTests.cs, test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000TestEncoder.cs, test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000TestEncoder.Coding.cs, test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000TestEncoder.Markers.cs */ + comment designRef /* Design: docs/design/canvas-net/codecs/jpeg2000-codec.md */ + comment verificationRef /* Verification: docs/verification/canvas-net/codecs/jpeg2000-codec.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net/codecs/jpeg2000-codec.yaml */ + } +} diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index b5f92a43..917299ba 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -890,6 +890,100 @@ path and returns an `ImageInfo`. - `ArgumentException`: Thrown when `path` is an empty string. - `InvalidDataException`: Thrown for the same conditions as `GetInfo(Stream)`. +### Jpeg2000Codec + +The `Jpeg2000Codec` static class decodes JPEG 2000 Part 1 images (JP2 files and raw codestreams) +into `Surface` pixel buffers. It is decode-only: there is no `Save`. It supports reversible and +irreversible coding, tiles and tile-parts, quality layers, all five progression orders, precincts, +every code-block style, regions of interest, 1 to 16 bit samples, subsampled components, and the +JP2 color, palette and channel-definition boxes. JPEG 2000 Part 2 extensions and High Throughput +(Part 15) codestreams are rejected with `UnsupportedImageFeatureException`. An embedded ICC profile +is reported but not applied. + +#### Jpeg2000Codec Methods + +##### Jpeg2000Codec.Load(Stream stream) / Jpeg2000Codec.Load(string path) + +```csharp +public static Surface Load(Stream stream) +public static Surface Load(string path) +``` + +Decodes the image to an RGBA `Surface` (gray expanded to RGB, CMYK converted to RGB, alpha +preserved). + +**Exceptions:** + +- `ArgumentNullException`: Thrown when `stream` or `path` is null. +- `ArgumentException`: Thrown when `path` is an empty string. +- `FileNotFoundException`: Thrown when the file at `path` does not exist. +- `InvalidDataException`: Thrown when the data is not JPEG 2000, exceeds `Surface.MaxDimension`, + or is truncated or corrupt. +- `UnsupportedImageFeatureException`: Thrown for valid but unsupported JPEG 2000 features. + +##### Jpeg2000Codec.GetInfo(Stream stream) / Jpeg2000Codec.GetInfo(string path) + +```csharp +public static ImageInfo GetInfo(Stream stream) +public static ImageInfo GetInfo(string path) +``` + +Returns the width, height, channel count and alpha presence from the container and codestream +header alone, without decoding pixels and without enforcing `Surface.MaxDimension`. Only the header +is read: for JP2 the boxes up to the codestream box, then the SOC and SIZ segments (at most about +64 KiB of the codestream), so the position of the stream afterwards is unspecified and the +codestream body is never read. A box before the codestream is skipped by seeking when +`stream.CanSeek` is `true`; on a non-seekable stream it is read and discarded (never buffered), up +to the 256 MiB input limit. A truncated file whose codestream box is longer than its data is only +rejected by `Load`/`Decode` (or by `GetInfo` on a seekable stream). Throws the same +argument and `InvalidDataException` errors as `Load`. + +##### Jpeg2000Codec.Decode(Stream stream) / Jpeg2000Codec.Decode(byte[] data) + +```csharp +public static Jpeg2000Image Decode(Stream stream) +public static Jpeg2000Image Decode(byte[] data) +``` + +Decodes to a `Jpeg2000Image` exposing `Width`, `Height`, `ColorSpace` (`Gray`, `Srgb`, `Cmyk` or +`Unknown`), `ColorChannelCount`, 8-bit interleaved `ColorSamples`, optional `AlphaSamples` (with +`AlphaPremultiplied`), `HasAlpha` (true when `AlphaSamples` is present), the `IccProfile` bytes, the +source `BitDepth` of the first color channel before 8-bit scaling, and `HasPalette` (whether a JP2 +palette was applied to `ColorSamples`). +Throws the same exceptions as `Load`. + +##### Jpeg2000Codec.Decode(..., Jpeg2000DecoderLimits limits) + +```csharp +public static Jpeg2000Image Decode(Stream stream, Jpeg2000DecoderLimits limits) +public static Jpeg2000Image Decode(byte[] data, Jpeg2000DecoderLimits limits) +``` + +Decodes with caller-supplied resource limits, for example tighter limits for untrusted input. +Throws `ArgumentNullException` for null limits and `ArgumentOutOfRangeException` for a +non-positive limit. `Jpeg2000DecoderLimits` is a record with `init` properties +(`MaxInputBytes` 256 MiB, `MaxWidth`/`MaxHeight` `Surface.MaxDimension`, `MaxTotalSamples` +2^27, `MaxTileSamples` 2^26, `MaxTiles` 65535, `MaxTilePrecincts` 2^18, `MaxTileCodeBlocks` +2^20, `MaxTilePackets` 2^22, `MaxProgressionChanges` 128, `MaxProgressionSteps` 2^30 and +`MaxTier1Work` 2^34); `Jpeg2000DecoderLimits.Default` holds the defaults. The progression and +tier-1 work ceilings additionally scale with the input length, so a small hostile stream cannot +consume the full budget. Data over a limit fails with `InvalidDataException`. + +The defaults favour accepting every valid image, so a hostile stream can still cost CPU time: +roughly 50 microseconds per input byte (Release build; about twice that in Debug) up to the +`MaxTier1Work` ceiling, which at the default is on the order of a minute or more of CPU for a +stream of about 1 MB. When decoding untrusted input, pass tighter limits sized to what you expect: + +```csharp +var limits = new Jpeg2000DecoderLimits +{ + MaxInputBytes = 8 * 1024 * 1024, + MaxTotalSamples = 1 << 24, // about 16 megapixel-samples + MaxTier1Work = 1L << 30, // hard cap on entropy-decoding work +}; +var image = Jpeg2000Codec.Decode(untrustedBytes, limits); +``` + ### SvgCodec `SvgCodec` is distributed via the separate `DemaConsulting.CanvasNet.Svg` NuGet package (namespace diff --git a/docs/verification/canvas-net-pdf/pdf-document.md b/docs/verification/canvas-net-pdf/pdf-document.md index e4500a49..9aa36f61 100644 --- a/docs/verification/canvas-net-pdf/pdf-document.md +++ b/docs/verification/canvas-net-pdf/pdf-document.md @@ -886,8 +886,8 @@ merely re-deriving the implementation's own output). Repeats the PNG-predictor c bytes compressed via `LZWDecode` instead of `FlateDecode` (using a test-only classic-LZW encoder, never shipped in `src/`), proving predictor reversal is gated on filter name (`FlateDecode` or `LZWDecode`) rather than merely on `/DecodeParms` presence. Renders an image XObject declaring an -unsupported filter (`/JPXDecode` - still genuinely unsupported, unlike `/LZWDecode`/ -`/CCITTFaxDecode`, both of which this and a later phase respectively implement), asserting +unsupported filter (`/JBIG2Decode` - still genuinely unsupported, unlike `/LZWDecode`/ +`/CCITTFaxDecode`/`/JPXDecode`, all of which are implemented), asserting `Codecs.UnsupportedImageFeatureException`. #### CanvasNetPdf-PdfDocument-LzwDecodeFilter: LZWDecode Decodes the PDF-Variant Algorithm, Fails Closed on Malformed Input @@ -1044,6 +1044,14 @@ bits before each row, rather than the implementation coincidentally tolerating z `PdfDocument_Images_DoOperator_DeviceCmykFlateDecode_PlacesExpectedPixels`, `PdfDocument_Images_DoOperator_DctDecodeJpeg_PlacesExpectedPixels`, `PdfDocument_Images_UnsupportedBitsPerComponent_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Images_RawOneBitGray_UnpacksSamples`, +`PdfDocument_Images_RawOneBitMultiRowPadding_UnpacksEachRowFromItsOwnByte` (width 9, height 2), +`PdfDocument_Images_RawFourBitRgb_UnpacksComponents`, +`PdfDocument_Images_RawTwoBitRgbTwoRows_PadsEachRow`, +`PdfDocument_Images_RawSixteenBit_UsesHighBytes` (16-bit gray and RGB base images), +`PdfDocument_Images_RawFourBitIndexed_LooksUpPalette`, +`PdfDocument_Images_RawOneBitTruncated_ThrowsInvalidDataException`, +`PdfDocument_Images_IndexedSixteenBit_ThrowsUnsupportedImageFeatureException`, `PdfDocument_Images_UnsupportedColorSpace_ThrowsUnsupportedImageFeatureException`, `PdfDocument_Images_DoOperator_UndefinedXObjectName_ThrowsInvalidDataException`, `PdfDocument_Images_DoOperator_MalformedOperandCount_ThrowsInvalidDataException`, @@ -1057,9 +1065,11 @@ including the PDF image-space row-0-is-top convention). Places a bare `DCTDecode XObject (an 8x8 solid-color surface encoded via `Codecs.JpegCodec.Save` at test-run time, quality 100 - a flat color block's DCT has only a DC coefficient, so the round-trip reproduces it within a small per-channel tolerance), asserting the decoded/composited pixel matches within that -tolerance. Renders an image XObject with an unsupported `/BitsPerComponent` (`1`) and, separately, +tolerance. Renders an image XObject with an unsupported `/BitsPerComponent` (`3`) and, separately, an unsupported `/ColorSpace` (`/Lab`), each asserting -`Codecs.UnsupportedImageFeatureException`. Renders `Do` with a name undeclared in +`Codecs.UnsupportedImageFeatureException`. A 1-bit gray image proves MSB-first unpacking and +full-range scaling, a short sub-byte buffer asserts `InvalidDataException`, and a 16-bit +`/Indexed` image asserts `UnsupportedImageFeatureException`. Renders `Do` with a name undeclared in `/Resources/XObject`, asserting `InvalidDataException`. A `[Theory]` renders `Do` with a malformed operand count/type, asserting `InvalidDataException` in every case. The end-to-end system-integration test independently proves the same `Do` compositing against a @@ -1067,14 +1077,130 @@ real, hand-authored fixture, asserting specific composited pixel colors at speci matching the fixture's known 2x2 source image, and a pixel outside the placed image's device-space footprint remains transparent. A dedicated regression test (regression guard for a confirmed real-world raw-overwrite alpha-compositing bug, invoking the private -`CompositeImageOntoSurface` method directly via reflection, since every image XObject this -package currently decodes is always fully opaque and so cannot exercise non-opaque sampling -end-to-end) proves each sampled source pixel is alpha-blended "over" the existing destination +`CompositeImageOntoSurface` method directly via reflection, which isolates the compositing +formula from image decoding) proves each sampled source pixel is alpha-blended "over" the existing destination pixel - a fully transparent source pixel with a non-matching stored RGB leaves the background completely unchanged, a fully opaque source pixel exactly replaces it, and a partially transparent source pixel blends to the exact expected bytes per the documented Porter-Duff "over" formula. +#### CanvasNetPdf-PdfDocument-ImageSoftMask: /SMask Images Become the Base Image's Alpha + +**Tests**: `PdfDocument_Images_SMaskOnRawImage_AppliesLuminanceAsAlpha`, +`PdfDocument_Images_SMaskOnDctImage_AppliesLuminanceAsAlpha`, +`PdfDocument_Images_Jpx_JpxSMask_AppliesLuminanceAsAlpha`, +`PdfDocument_Images_SMaskOneBit_AppliesBinaryAlpha`, +`PdfDocument_Images_SMaskSixteenBit_AppliesHighByteAsAlpha`, +`PdfDocument_Images_SMaskFourBitWithDecode_InvertsMask` + +`PdfDocumentJpxTests.cs` builds minimal PDFs at test-run time. A 1x1 raw gray image, an 8x8 +`DCTDecode` image and a JPX image each carry an `/SMask`; the rendered pixel's alpha equals the +mask luminance (the JPX case uses a 2x1 mask over an 8x8 base to prove nearest-neighbor +resampling of the mask). A 1-bit 2x1 mask yields alpha `0` and `255`, a 16-bit mask contributes +its high byte (`0x8000` gives alpha `128`), and a 4-bit mask with `/Decode [1 0]` yields `204`, +proving every bit depth flows through the shared sample path instead of failing the page. + +#### CanvasNetPdf-PdfDocument-ImageSoftMaskInvalid: Invalid /SMask Entries Fail Closed + +**Tests**: `PdfDocument_Images_SMaskNotAStream_ThrowsInvalidDataException`, +`PdfDocument_Images_SMaskNotImageSubtype_ThrowsInvalidDataException`, +`PdfDocument_Images_SMaskNotGray_ThrowsInvalidDataException` + +An `/SMask` referring to a plain dictionary, an `/SMask` stream whose `/Subtype` is not `/Image` +(a gray `/Form` stream), and an `/SMask` image in `DeviceRGB` each assert `InvalidDataException`. + +#### CanvasNetPdf-PdfDocument-ImageDecodeArray: /Decode Applies to Every Sample Encoding + +**Tests**: `PdfDocument_Images_RawWithDecode_InvertsSamples`, +`PdfDocument_Images_SMaskRawWithDecode_InvertsMask`, +`PdfDocument_Images_Jpx_DecodeArray_InvertsSamples`, +`PdfDocument_Images_Jpx_JpxSMaskWithDecode_InvertsMask`, +`PdfDocument_Images_DoOperator_CcittFaxWithDecode_InvertsPixels`, +`PdfDocument_Images_DoOperator_IndexedWithDecode_RemapsIndices`, +`PdfDocument_Images_DctRgbWithDecode_InvertsSamples`, +`PdfDocument_Images_DctGrayWithDecode_InvertsSamples`, +`PdfDocument_Images_DctFourChannelWithDecode_ThrowsInvalidDataException` + +`/Decode [1 0]` inverts a raw gray image, a raw soft-mask image (alpha `255 - 64`), a JPX image +and a JPX soft-mask image, and a CCITT image (black and white swap). `/Decode [2 0]` over a 2-bit +`/Indexed` image maps raw indices `0`/`3` to palette entries `2`/`0`. A 3-channel JPEG +(`/Decode [1 0 1 0 1 0]`, compared with the undecoded render and the exact expected inverted color) +and a 1-channel real-encoder grayscale JPEG fixture (`/Decode [1 0]`) are remapped exactly. A +hand-built 4-component JPEG header with a `/Decode` asserts `InvalidDataException` (the JPEG codec +rejects CMYK/YCCK), proving the decode is never silently ignored. + +#### CanvasNetPdf-PdfDocument-ImageDecodeArrayInvalid: Malformed /Decode Fails Closed + +**Tests**: `PdfDocument_Images_RawWithWrongLengthDecode_ThrowsInvalidDataException`, +`PdfDocument_Images_Jpx_DecodeArrayWrongLength_ThrowsInvalidDataException` + +A `/Decode` array of the wrong length on a raw and on a JPX image each asserts +`InvalidDataException`. + +#### CanvasNetPdf-PdfDocument-JpxDecode: JPXDecode Images Are Decoded and Composited + +**Tests**: `PdfDocument_Images_Jpx_RealEncoderFixture_RendersCloseToSourcePixels`, +`PdfDocument_Images_Jpx_GrayWithoutColorSpace_UsesJp2ColorSpace` + +`PdfDocumentJpxTests.cs` embeds JPEG 2000 streams produced by the test-only `Jpeg2000TestEncoder` +(linked into the PDF test project from `DemaConsulting.CanvasNet.Tests/Codecs`, not duplicated) +and asserts the composited pixel. `PdfDocument_Images_Jpx_RealEncoderFixture_RendersCloseToSourcePixels` +embeds a gray JP2 generated by ImageMagick/OpenJPEG (`Jpeg2000Fixtures/gray.jp2`, linked from the +core test project) and asserts the rendered page matches its source PNG within a mean-absolute- +difference bound (the renderer resamples) with the area outside the image left transparent. + +#### CanvasNetPdf-PdfDocument-JpxColorSpace: JPXDecode Honors the JP2 and PDF Color Spaces + +**Tests**: `PdfDocument_Images_Jpx_RgbWithoutColorSpace_UsesJp2ColorSpace`, +`PdfDocument_Images_Jpx_CmykWithoutColorSpace_UsesJp2ColorSpace`, +`PdfDocument_Images_Jpx_ExplicitDeviceRgb_HonorsColorSpace`, +`PdfDocument_Images_Jpx_ExplicitDeviceCmyk_HonorsColorSpace`, +`PdfDocument_Images_Jpx_IccBasedColorSpace_ResolvesByComponentCount`, +`PdfDocument_Images_Jpx_IndexedColorSpace_OverridesJp2ColorSpace` + +RGB and CMYK streams without `/ColorSpace` assert the JP2's own color space is used; explicit +`DeviceRGB`/`DeviceCMYK`, `ICCBased` (by component count) and `Indexed` color spaces assert +`/ColorSpace` overrides it. + +#### CanvasNetPdf-PdfDocument-JpxSMaskInData: /SMaskInData Selects How the Opacity Channel Applies + +**Tests**: `PdfDocument_Images_Jpx_SMaskInDataZero_IgnoresAlphaChannel`, +`PdfDocument_Images_Jpx_SMaskInDataOne_AppliesAlphaChannel`, +`PdfDocument_Images_Jpx_SMaskInDataTwo_UnpremultipliesColor`, +`PdfDocument_Images_Jpx_SMaskInDataTwoCmyk_UnpremultipliesBeforeConversion`, +`PdfDocument_Images_Jpx_SMaskAndSMaskInData_SMaskWins` + +`/SMaskInData` 0, 1 and 2 assert the alpha channel is ignored, applied, and un-premultiplied. The +CMYK case premultiplies a full-strength cyan by alpha 128 and asserts the result is the exact cyan +of the unpremultiplied sample, which would not hold if the un-premultiply ran after the CMYK to +RGB conversion. An explicit `/SMask` is asserted to win over `/SMaskInData`. + +#### CanvasNetPdf-PdfDocument-JpxInvalidInput: Invalid JPXDecode Input Fails Closed with InvalidDataException + +**Tests**: `PdfDocument_Images_Jpx_GarbageData_ThrowsInvalidDataException`, +`PdfDocument_Images_Jpx_TruncatedData_ThrowsInvalidDataException`, +`PdfDocument_Images_Jpx_OversizedHeader_ThrowsInvalidDataException`, +`PdfDocument_Images_Jpx_PatternColorSpace_ThrowsInvalidDataException`, +`PdfDocument_Images_Jpx_ColorSpaceComponentMismatch_ThrowsInvalidDataException`, +`PdfDocument_Images_Jpx_InvalidSMaskInData_ThrowsInvalidDataException`, +`PdfDocument_Images_Jpx_CombinedWithAnotherFilter_ThrowsInvalidDataException` + +Garbage and truncated JPEG 2000 data, a codestream header patched to 100000x100000 (rejected by +the explicit decoder limits before any plane is allocated), a `/Pattern` `/ColorSpace`, a +component-count mismatch, an invalid `/SMaskInData` and `JPXDecode` combined with another filter +each assert `InvalidDataException` (an exact type, not a set of acceptable types). + +#### CanvasNetPdf-PdfDocument-JpxUnsupportedCombination: Unrepresentable JPXDecode Combinations Fail Closed + +**Tests**: `PdfDocument_Images_Jpx_ColorSpaceOverJp2Palette_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Images_Jpx_IndexedColorSpaceNonEightBit_ThrowsUnsupportedImageFeatureException`, +`PdfDocument_Images_Jpx_SMaskInDataTwoIndexed_ThrowsUnsupportedImageFeatureException` + +A `/ColorSpace` over JP2 data with its own palette, an `Indexed` space over 4-bit samples (the +decoder scales samples to 8 bits) and an `Indexed` space with `/SMaskInData 2` (premultiplied +indices are meaningless) each assert `UnsupportedImageFeatureException`; the palette alone (no +override) renders. + #### CanvasNetPdf-PdfDocument-FormXObjects: Do Executes Nested Form XObject Content Streams **Tests**: `PdfDocument_Images_DoOperator_FormXObject_PaintsNestedContentStream`, diff --git a/docs/verification/canvas-net.md b/docs/verification/canvas-net.md index 67070255..d5ff7e63 100644 --- a/docs/verification/canvas-net.md +++ b/docs/verification/canvas-net.md @@ -96,6 +96,16 @@ Asserts the decoded `Surface`'s dimensions and single pixel match the color sele stream's Global Color Table index, confirming the system's public GIF load API integrates correctly with `Surface`. +### JPEG 2000 Codec Coverage + +The system-level JPEG 2000 requirements (`CanvasNet-Lib-Jpeg2000*`) reuse the `Jpeg2000Codec` unit tests +(see _Jpeg2000Codec Unit Verification Design_, `canvas-net/codecs/jpeg2000-codec.md`) as evidence, +since `Jpeg2000Codec` is decode-only and its public `Load`, `GetInfo` and `Decode` API is exercised +end-to-end against test-encoded streams there. CanvasNet_SystemIntegration_Jpeg2000Load_ReturnsExpectedPixel +additionally loads a real ImageMagick/OpenJPEG-encoded fixture through the public API and compares it +with its PNG source. The PDF integration is verified in the +`CanvasNetPdf` verification design. + ### Integration: Composite Color Over Surface Returns Expected Pixel **Test**: `CanvasNet_SystemIntegration_CompositeColorOverSurface_ReturnsExpectedPixel` diff --git a/docs/verification/canvas-net/codecs.md b/docs/verification/canvas-net/codecs.md index df6609f9..437be3c0 100644 --- a/docs/verification/canvas-net/codecs.md +++ b/docs/verification/canvas-net/codecs.md @@ -1,20 +1,20 @@ ## Codecs Subsystem Verification Design This document describes the subsystem-level verification strategy for the `Codecs` subsystem -(the `BmpCodec`, `PngCodec`, `TiffCodec`, `JpegCodec`, and `GifCodec` units). +(the `BmpCodec`, `PngCodec`, `TiffCodec`, `JpegCodec`, `GifCodec`, and `Jpeg2000Codec` units). -Note: SVG rasterization verification was previously modeled as a sixth unit of this subsystem's -verification, but is now provided by the separate `CanvasNetSvg` system (its own package, +Note: SVG rasterization verification is not part of this subsystem's verification; it is +provided by the separate `CanvasNetSvg` system (its own package, `DemaConsulting.CanvasNet.Svg`) โ€” see _CanvasNetSvg System Verification Design_ (`../canvas-net-svg.md`) and _SvgCodec Unit Verification Design_ (`../canvas-net-svg/svg-codec.md`). ### Verification Approach -The `Codecs` subsystem is verified through its five constituent units' tests (see +The `Codecs` subsystem is verified through its six constituent units' tests (see _BmpCodec Unit Verification Design_, _PngCodec Unit Verification Design_, -_TiffCodec Unit Verification Design_, _JpegCodec Unit Verification Design_, and -_GifCodec Unit Verification Design_ under `codecs/`), +_TiffCodec Unit Verification Design_, _JpegCodec Unit Verification Design_, +_GifCodec Unit Verification Design_, and _Jpeg2000Codec Unit Verification Design_ under `codecs/`), together with the system-level round-trip (or, for the decode-only `GifCodec`, load-only) integration tests in `CanvasNetTests.cs` that exercise each codec end-to-end against a `Surface`. No separate subsystem-level tests otherwise exist; the subsystem-level requirements @@ -24,21 +24,21 @@ reuse the corresponding unit and system-integration tests as verification eviden - **Framework**: xUnit v3 running under the .NET SDK - **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline -- **Mocking**: None required; `BmpCodec`, `PngCodec`, `TiffCodec`, `JpegCodec`, and `GifCodec`'s - only dependency is the in-house `Canvas` subsystem's `Surface` unit +- **Mocking**: None required; `BmpCodec`, `PngCodec`, `TiffCodec`, `JpegCodec`, `GifCodec`, and + `Jpeg2000Codec`'s only dependency is the in-house `Canvas` subsystem's `Surface` unit ### Acceptance Criteria -The `Codecs` subsystem's verification passes when every unit test scenario described in the five +The `Codecs` subsystem's verification passes when every unit test scenario described in the six codec unit verification documents under `codecs/`, and every `CanvasNet_SystemIntegration_*` round-trip/load test referenced by the `Codecs` subsystem requirements, pass without error or unexpected exception. ### Test Scenarios -The `Codecs` subsystem's test scenarios are those named in each of the five constituent unit -verification documents under `codecs/` (_BmpCodec_, _PngCodec_, _TiffCodec_, _JpegCodec_, and -_GifCodec Unit Verification Design_), covering `Load`/`Save`/`GetInfo` normal-path round-trips, +The `Codecs` subsystem's test scenarios are those named in each of the six constituent unit +verification documents under `codecs/` (_BmpCodec_, _PngCodec_, _TiffCodec_, _JpegCodec_, _GifCodec_, and +_Jpeg2000Codec Unit Verification Design_), covering `Load`/`Save`/`GetInfo` normal-path round-trips, malformed/truncated-input rejection, and `ImageInfo.CanDecode`/`UnsupportedImageFeatureException` well-formed-but-unsupported handling, plus the system-level `CanvasNet_SystemIntegration_*` round-trip (or, for `GifCodec`, load-only) tests that exercise each codec end-to-end against a diff --git a/docs/verification/canvas-net/codecs/jpeg2000-codec.md b/docs/verification/canvas-net/codecs/jpeg2000-codec.md new file mode 100644 index 00000000..e31d84ab --- /dev/null +++ b/docs/verification/canvas-net/codecs/jpeg2000-codec.md @@ -0,0 +1,216 @@ +### Jpeg2000Codec Unit Verification Design + +This document describes the unit-level verification strategy for the `Jpeg2000Codec` class. + +#### Verification Approach + +The `Jpeg2000Codec` unit is verified by `Jpeg2000CodecTests.cs`, which feeds the decoder streams +produced by the test-only `Jpeg2000TestEncoder` (`Jpeg2000TestEncoder.cs`, +`Jpeg2000TestEncoder.Coding.cs`, `Jpeg2000TestEncoder.Markers.cs`). The encoder generates JPEG 2000 +codestreams and JP2 files with a chosen combination of features, so each decoder feature is +covered by a round trip: reversible streams must reconstruct exactly and irreversible streams +must fall within a stated tolerance. The MQ coder is additionally verified against the ITU-T T.88 +test sequence. Because a test-only encoder written alongside the decoder could share a misreading of +the standard, Jpeg2000FixtureTests.cs adds independent evidence: small fixtures generated by us +with ImageMagick/OpenJPEG from synthetic images (Jpeg2000Fixtures/, see its README.md for +provenance) are decoded and compared for exact equality with the source PNG decoded by +PngCodec. The fixtures cover reversible 5/3 with RCT, all five progression orders, three quality +layers, reduced decomposition levels, gray, alpha, 16-bit samples and a raw codestream, with each +fixture's COD/SIZ marker bytes asserted so the intended feature is proven present; a truncated +(lossy) fixture is checked by PSNR. ImageMagick 7.1.2 cannot produce tiles, precincts or the 9/7 +wavelet, so those remain covered by the test-only encoder. No mocking is required; the unit's +dependencies (`Surface`, `ImageInfo`) are +in-house types. + +#### Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Mocking**: None required +- **Isolation**: Every test builds its own in-memory stream; file-path tests use a temporary file + +#### Unit-Level Test Scenarios + +##### CanvasNet-Codecs-Jpeg2000Codec-Load: Load Produces RGBA Pixels + +**Tests**: `Jpeg2000Codec_Load_Stream_ReturnsPixels`, `Jpeg2000Codec_Load_Grey_ExpandsToRgb`, +`Jpeg2000Codec_Load_Alpha_PopulatesAlpha`, `Jpeg2000Codec_Load_Path_ReturnsPixels` + +Loads encoded images from a stream and a file path and checks the pixels, gray-to-RGB expansion +and alpha. Jpeg2000Codec_Load_RealEncoderLosslessFixture_MatchesSourcePixelsExactly loads each +real-encoder lossless fixture and requires pixel-exact equality with its source PNG, and +Jpeg2000Codec_Load_RealEncoderLossyFixture_HasAcceptablePsnr requires at least 30 dB PSNR for the +lossy fixture. Jpeg2000Codec_Decode_RealEncoderFixtures_ReportChannelLayout checks the gray, +alpha and 16-bit channel layouts. + +##### CanvasNet-Codecs-Jpeg2000Codec-Decode: Decode Reconstructs Component Samples + +**Tests**: the `Jpeg2000Codec_Decode_Reversible*`, `_FourComponents`, `_DepthAndSign`, +`_SignedRct`, `_Irreversible*`, `_SubsampledComponents`, `_PerComponentLevels*` and +`_RealEncoderFixtures_ReportChannelLayout` tests (the reqstream file lists each by full name). + +Round-trips reversible and irreversible streams across decomposition levels, odd sizes, color +transforms, bit depths, signedness and subsampling. + +##### CanvasNet-Codecs-Jpeg2000Codec-EntropyCoding: Tier-1 and MQ Decoding + +**Tests**: `Jpeg2000Codec_MqEncoder_T88TestSequence_MatchesReferenceCodeStream`, +`Jpeg2000Codec_MqDecoder_T88ReferenceCodeStream_RecoversTestSequence`, the +`Jpeg2000Codec_Decode_CodeBlockStyle*` tests, `_IrreversibleBypassTermall` and the `_Roi*` tests. + +Verifies the MQ decoder against the T.88 sequence, every code-block style and the region of +interest max-shift. + +##### CanvasNet-Codecs-Jpeg2000Codec-PacketStructure: Tier-2 Packets + +**Tests**: the `_Precincts`, `_PerResolutionPrecincts`, `_Layers`, `_ProgressionOrders`, +`_PositionProgressionWithOffsets` and `_Poc*` (main header, tile header, tile overriding main, +`_PocLaterVolumeLargerLayerEnd`, `_PocTileHeaderLaterVolumeLargerLayerEnd`) +tests, plus the lossless real-encoder fixture. + +Round-trips streams using every progression order, layers, precinct sizes, tiles and offsets. +The decoder tracks every (component, resolution, precinct, layer) packet in a seen bitmap, so a +later POC volume with a larger layer end skips packets an earlier volume already sent and reads +only the new layers (T.800 B.12.2); the `_PocLaterVolumeLargerLayerEnd*` tests use layers 0..1 +followed by layers 0..3 over the same resolutions and components and require an exact round trip. +The real-encoder fixtures add independent exact-match evidence for the RLCP, RPCL, PCRL and CPRL +progression orders (marker-verified), three quality layers and reduced decomposition levels. + +##### CanvasNet-Codecs-Jpeg2000Codec-Codestream: Codestream Headers + +**Tests**: the `_Tiles`, `_TileAndImageOffsets`, `_TileHeaderOverrides`, `_SopEph`, `_Ppm`, +`_Ppt`, `_TileParts`, `_ExtraMarkers_AreSkipped`, `_ZeroPsotMissingEoc`, `_Roi*` and +`_TilePartIndexes_AreCheckedAndCountIsAdvisory`, `_ParamMarkersInLaterTilePart_ThrowsInvalidData` +and `_PocInLaterTilePart_RoundTripsExactly` tests, plus the marker-precedence tests +`_TileCodOverridesMainCoc_ForAllComponents`, `_TileCocOverridesTileCod_ForItsComponent`, +`_NoTileCod_MainCocStillApplies` and `_TilePrecedenceIrreversible`. + +Verifies tile-part headers, progression order changes, packed packet headers, SOP/EPH markers and +tolerated marker variants; COD/COC/QCD/QCC/RGN in a later tile-part are rejected, POC is accepted. +The precedence tests use the test encoder to build a main COC/QCC for one component together with a +tile COD/QCD (the tile COD wins for all components), a tile COC/QCC next to a tile COD/QCD (the +tile COC wins for its component) and a tile without overrides (the main COC still applies); each +must round-trip exactly, which only holds if the decoder applies the T.800 A.6.2/A.6.4 order. + +##### CanvasNet-Codecs-Jpeg2000Codec-Jp2Container: JP2 Boxes + +**Tests**: the `Jpeg2000Codec_Decode_Jp2*` tests. + +Checks enumerated color spaces, sYCC conversion, ICC profile reporting, box length forms, +palettes, and channel definitions (alpha and reordering). `_Jp2BadSignatureContent` proves a +signature box whose content is not `0D 0A 87 0A` is rejected with `InvalidDataException`, and +`_Jp2SignedPaletteColumn` proves a palette column with the signed flag set is rejected with +`UnsupportedImageFeatureException`. `Jpeg2000Codec_Decode_CmapWithoutPclr_IsIgnored` proves a `cmap` +box without a `pclr` box does not remap channels. The `Jpeg2000Codec_Jp2*` tests (in +`Jpeg2000ValidationTests`) pin the required-box rule on `Decode` and on `GetInfo` (seekable and +non-seekable): a missing or misplaced `ftyp`, a bad `ftyp` length, a missing `jp2h`, a `jp2h` after +`jp2c` or without `ihdr`, a wrong `ihdr` length and an `ihdr` width or height that differs from SIZ +are `InvalidDataException`, while a missing `colr`, another brand, differing `ihdr` NC/BPC/C/UnkC/IPR +fields, a second `ihdr` and `ihdr` after `colr` are accepted. + +##### CanvasNet-Codecs-Jpeg2000Codec-GetInfo: Header-Only Information + +**Tests**: `Jpeg2000Codec_GetInfo_RawCodestream_ReportsDimensionsAndChannels`, +`Jpeg2000Codec_GetInfo_Jp2WithAlpha_CountsAlphaChannel`, +`Jpeg2000Codec_GetInfo_ImageOffset_ReportsWidthMinusOffset` + +Checks dimensions, channel count, alpha presence and image-offset handling, including +Jpeg2000Codec_GetInfo_RealEncoderFixtures_ReportDimensions for real-encoder files. The +`_ReadsOnlyHeaderPrefix` tests wrap a raw codestream and a JP2 file followed by a 4 MiB payload in a +byte-counting stream (seekable and non-seekable) and require that fewer than 70,000 bytes are read; +`_LargeSkippedBox_SeekOrDiscard` puts a 4 MiB box before the codestream box (skipped by seeking, +or discarded when the stream cannot seek); `_LargeIccProfile_IsNotBuffered` and +`_LargeBoxInsideJp2h_IsNotBuffered` (seekable and non-seekable) put a 4 MiB ICC `colr` box or `free` +box inside `jp2h` and require identical results with no single read above 70,000 bytes (and, when +seekable, fewer than 70,000 bytes consumed); `_HostileJp2hLength` declares a 200 MiB `jp2h` and +`_OversizedPaletteBox` a 2 MiB `pclr`, both rejected with `InvalidDataException` and bounded reads; +`_TruncatedBox` and `_SeekableAboveInputCap` pin the +error behavior. + +##### CanvasNet-Codecs-Jpeg2000Codec-ArgumentValidation: Argument Validation + +**Tests**: `Jpeg2000Codec_NullOrEmptyArguments_Throw`, +`Jpeg2000Codec_Load_MissingFile_ThrowsFileNotFound` + +Checks the null, empty-path and missing-file exceptions for every public method. + +##### CanvasNet-Codecs-Jpeg2000Codec-Malformed: Malformed Data + +**Tests**: `Jpeg2000Codec_Load_NotJpeg2000_ThrowsInvalidData`, +`Jpeg2000Codec_Load_ExceedsMaxDimension_ThrowsInvalidData`, and the `_EveryTruncationOfSmallStream`, +`_TruncationsOfLargeStreams`, `_BadMarkerLengths`, `_BadTilePartLengths`, `_GarbageTileData`, +`_HostileBoxes` and `_HostilePoc` tests, the random fuzz tests `_RandomBitFlips` and +`_HeaderByteSubstitutions`, plus the resource-limit tests `_ImageBeyondDimensionLimit`, +`_TooManyTiles`, `_TooManyCodeBlocks`, `_TooManyPrecincts`, `_TooManyPackets`, +`_MoreProgressionChangesThanCap`, `_OverlappingProgressionVolumes`, `_TightProgressionLimit`, +`_PocLeavingPacketsUncovered`, `_RepeatedPocEntriesWithinCap`, +`Jpeg2000Codec_DecodeBudget_IsCumulativeAndScalesWithInput`, `_TightTier1Limit`, +`_HostileTier1Work_ThrowsInvalidDataWhenOverBudget` (explicit `MaxTier1Work` cap with a decoding control), +`_HostileMaximumImageTier1Work_ExceedsDefaultBudget` (budget arithmetic only), `_SmallHostileShape_Decodes`, +`_LargeLosslessFlatImage_DecodesUnderDefaultLimits` (a 4096 x 4096 flat 16-bit lossless image +whose work of about 7.2 x 10^8 sample-passes from a 158 KB stream exceeded the former 2^12 +per-byte allowance decodes under the default limits), +`_MissingTile`, `_CustomLimits`, `_InvalidLimits`, `_HostileExponents`, +`_WrongSegmentationSymbol`, `_SegmentationSymbols`, and the exact-length checks +`_OddLengthExpoundedQcd`, `_OddLengthExpoundedQcc`, `_DerivedQcdTrailingByte`, +`_MainQuantEntryCountMismatch` / `_TileQuantEntryCountMismatch` (a style-0 +QCD or QCC with extra or missing entries in the main or a tile-part header), +`_QcdBeforeCod_ValidatedAgainstLaterLevels` (the entry count is checked against the levels of a COD +that arrives later), `_CodestreamSegmentLengthMismatch` (SIZ, COD, COC and RGN with a trailing or +missing byte), +`_SopWrongLength` and `_Jp2BoxLengthMismatch` (colr, pclr and cdef with a trailing byte). +The `Jpeg2000ValidationTests` class adds the Annex A audit with small hand-patched streams: +`_MarkerNotAllowedInMainHeader` (SOC, SOD, EOC, SIZ, PPT, PLT, SOP), `_MarkerNotAllowedInTilePartHeader` +(CAP, SIZ, TLM, PLM, PPM, CRG, SOP), the accepted skipped markers +(`_SkippedMarkerInMainHeader`, `_SkippedMarkerInTilePartHeader`), `_MarkerOrdering`, `_MissingSod`, +`_BytesAfterEoc` (leniency), `_PpmIndexes` / `_PptIndexes` (a repeated index is rejected, a gap is +accepted), `_InvalidFieldValue` (one case per SIZ, COD, QCD, COC, QCC, RGN, POC and SOT field range) and +`_MaximumCodeBlockArea` (the xcb + ycb = 12 boundary). All assert exception types and are deterministic. + +Checks (including Jpeg2000Codec_Decode_RealEncoderFixtureTruncated_FailsClosed on a real-encoder +file) that corrupt, truncated and hostile data fail with `InvalidDataException` (or an +unsupported-feature exception) without unhandled exceptions. Named malformed cases +assert the exact exception type and that it carries no inner exception, proving the failure came +from explicit validation and not from the `Guard` backstop; the assertion also checks that the +message names the specific cause. Each resource-limit test builds a stream with dimensions within +the dimension limit (or tight custom `Jpeg2000DecoderLimits`) so that the named cap, and not an +earlier check, is the one that fires. Only the two random-mutation fuzz tests accept a successful +decode, because a mutation may leave a stream valid; they still require that any failure is a +plain `InvalidDataException` or `UnsupportedImageFeatureException`. `_EveryTruncationOfSmallStream` tests +every prefix length of a small multi-tile stream. No test asserts wall-clock time, which is unreliable +under parallel CI load: work is bounded deterministically by explicit `Jpeg2000DecoderLimits` (the fuzz +tests use a `MaxTier1Work` cap), and the only timeouts are generous hang guards. Hostile streams +(thousands of POC entries, precinct counts far beyond the data, and a maximum-size image with maximum +coding passes per block) fail on their budget: `_HostileTier1Work_ThrowsInvalidDataWhenOverBudget` trips an +explicit `MaxTier1Work` cap on a 2048 x 2048 stream and decodes a 128 x 128 control under a cap that fits, +and `_HostileMaximumImageTier1Work_ExceedsDefaultBudget` checks the default budget arithmetic for the +76 KB maximum-image stream without paying for its CPU-expensive decode. A codestream cut at a tile +boundary fails because the tile is missing. + +##### CanvasNet-Codecs-Jpeg2000Codec-Unsupported: Unsupported Features + +**Tests**: `Jpeg2000Codec_Decode_Part2Capabilities_ThrowsUnsupported`, +`_HighThroughput_ThrowsUnsupported`, `_UnknownTransforms_ThrowsUnsupported`, +`_UnknownRoiStyle_ThrowsUnsupported`, `_FiveComponentsWithoutCdef_ThrowsUnsupported`, +`_TooManyComponents_ThrowsUnsupported` (17 components, feature `jpeg2000-component-count`), +`_RoiShiftBeyondMaxBitPlanes_ThrowsUnsupported`, `_FourChannelsInUnknownColorSpace_ThrowsUnsupported`, +`_DepthAbove16_ThrowsUnsupported` + +Checks that valid but unsupported features raise `UnsupportedImageFeatureException`. The CMYK rule +is also checked: `_RawFourComponents_IsCmyk` (heuristic for unspecified color) and +`_SrgbWithExtraChannelWithoutCdef_IgnoresExtraChannel`. + +#### Requirements Coverage + +| Requirement | Covered by | +| --------------------------------------------------- | ---------------------------------------- | +| `CanvasNet-Codecs-Jpeg2000Codec-Load` | Load tests | +| `CanvasNet-Codecs-Jpeg2000Codec-Decode` | Decode round-trip tests | +| `CanvasNet-Codecs-Jpeg2000Codec-EntropyCoding` | MQ, code-block style and ROI tests | +| `CanvasNet-Codecs-Jpeg2000Codec-PacketStructure` | Tile, precinct, layer, progression tests | +| `CanvasNet-Codecs-Jpeg2000Codec-Codestream` | Header override, POC, PPM/PPT, tile-part | +| `CanvasNet-Codecs-Jpeg2000Codec-Jp2Container` | JP2 box tests | +| `CanvasNet-Codecs-Jpeg2000Codec-GetInfo` | GetInfo tests | +| `CanvasNet-Codecs-Jpeg2000Codec-ArgumentValidation` | Argument validation tests | +| `CanvasNet-Codecs-Jpeg2000Codec-Malformed` | Malformed and robustness tests | +| `CanvasNet-Codecs-Jpeg2000Codec-Unsupported` | Unsupported-feature tests | diff --git a/docs/verification/introduction.md b/docs/verification/introduction.md index 478d9887..9f9f1d15 100644 --- a/docs/verification/introduction.md +++ b/docs/verification/introduction.md @@ -30,7 +30,8 @@ constituent software items, specifically: - **Canvas (Subsystem)** โ€” Pixel-buffer primitives: the `Surface` unit (mutable, in-memory 32-bit RGBA pixel buffer with span-based row access) and the `Rgba32` unit - **Codecs (Subsystem)** โ€” Image format codecs: `BmpCodec`, `PngCodec`, `TiffCodec`, - `JpegCodec`, and `GifCodec`, each converting to and from a `Surface` pixel buffer + `JpegCodec`, `GifCodec`, and `Jpeg2000Codec`; the first four convert to and from a `Surface` + pixel buffer, while `GifCodec` and `Jpeg2000Codec` are decode-only loaders - **Geometry (Subsystem)** โ€” Vector-geometry primitives: `Rect`, `Path`, `BezierFlattening`, `SvgArcConverter`, and `CornerRoundEffect` - **Drawing (Subsystem)** โ€” Vector rasterization and stroke conversion: `PathFiller`, diff --git a/requirements.yaml b/requirements.yaml index 8e08e518..7d630594 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -18,6 +18,7 @@ includes: - docs/reqstream/canvas-net/codecs/tiff-codec.yaml - docs/reqstream/canvas-net/codecs/jpeg-codec.yaml - docs/reqstream/canvas-net/codecs/gif-codec.yaml + - docs/reqstream/canvas-net/codecs/jpeg2000-codec.yaml - docs/reqstream/canvas-net/geometry.yaml - docs/reqstream/canvas-net/geometry/rect.yaml - docs/reqstream/canvas-net/geometry/path.yaml diff --git a/src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs b/src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs index 69cf1e62..16094e9f 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/NamespaceDoc.cs @@ -26,7 +26,8 @@ namespace DemaConsulting.CanvasNet.Pdf; /// fonts are all supported, with embedded font programs used directly and non-embedded simple /// fonts automatically substituted with a matching system or bundled font (only /// /MMType1 is unsupported) - placed raster image XObjects (DCTDecode/ -/// CCITTFaxDecode (Group 4)/raw samples through the full supported /Filter +/// CCITTFaxDecode (Group 4)/JPXDecode (JPEG 2000, with /SMask and +/// /SMaskInData transparency)/raw samples through the full supported /Filter /// pipeline: FlateDecode, LZWDecode, ASCII85Decode, /// ASCIIHexDecode, and RunLengthDecode, each with PNG/TIFF predictor reversal /// where applicable), placed Form XObjects (nested content streams with their own diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.cs index a9acb726..2e3750db 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Filters.cs @@ -107,7 +107,8 @@ public sealed partial class PdfDocument /// (including DCTDecode and CCITTFaxDecode, both of which /// PdfDocument.Images.cs always detects and bypasses - decoding each directly via /// Codecs.JpegCodec/ respectively - before ever calling - /// this method; and JPXDecode, which remains unsupported) is rejected with + /// this method; and JPXDecode, which is likewise detected and bypassed, decoded + /// directly via Codecs.Jpeg2000Codec) is rejected with /// . This is the same behavior Phase 1/2 /// already relied on for cross-reference streams, object streams, and page /// /Contents (all of which only ever use a bare FlateDecode filter with no diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs index 25da2a21..578acfc8 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.Images.cs @@ -239,8 +239,9 @@ private Matrix3x2 ReadOptionalMatrix(PdfObject dictionary, string key = "Matrix" } /// - /// Decodes an image XObject stream into a fully opaque of its - /// declared /Width x /Height. + /// Decodes an image XObject stream into a of its declared + /// /Width x /Height: opaque unless the image has an /SMask (or JPX + /// opacity channel), which supplies the alpha channel. /// /// /// A /Filter /DCTDecode image (and no other filter) is decoded directly via @@ -264,25 +265,109 @@ private Matrix3x2 ReadOptionalMatrix(PdfObject dictionary, string key = "Matrix" /// case decodes via the general FlateDecode(+predictor) pipeline and interprets /// the resulting raw samples per /ColorSpace (device color spaces, /ICCBased, /// and /Indexed - see ) and /BitsPerComponent - /// (8 only). /SMask//Mask are never consulted - every decoded image - /// is treated as fully opaque, a documented Phase 3 limitation. + /// (1, 2, 4, 8 or 16; 16-bit samples keep their high + /// byte, and /Indexed images may not be 16-bit). A /Filter /JPXDecode image (and no other filter) is decoded + /// by . An explicit /SMask image (any supported + /// encoding, including JPXDecode) supplies the alpha channel, resampled to the + /// base image with nearest-neighbor sampling (the mask must be a single-component, + /// non-/Indexed image and is multiplied into the base surface in place); the + /// /Decode array is applied to the samples of every encoding (including soft-mask + /// images and DCTDecode images; 4-channel CMYK/YCCK JPEGs are rejected outright by + /// , so a /Decode can never be silently ignored on one); /Mask (stencil/color-key masking) and + /// /Matte are never consulted, a documented limitation. JPEG 2000 is never + /// decoded from an inline image because inline images (BI/ID/EI) + /// are not supported at all (the operators are ignored), which also satisfies the + /// specification's restriction of JPXDecode to image XObjects. /// /// /// Thrown when /Width//Height is missing, not a number, or not positive, - /// when /ColorSpace is missing (for a non-CCITTFaxDecode image), when - /// DCTDecode is combined with any other filter, when CCITTFaxDecode is + /// when /ColorSpace is missing (for a non-CCITTFaxDecode/non-JPXDecode image), when + /// DCTDecode or JPXDecode is combined with any other filter, when + /// CCITTFaxDecode is /// combined with any other filter, or propagated from for a /// malformed/truncated CCITT bit stream. /// /// /// Thrown when /ColorSpace names an unsupported color space, when - /// /BitsPerComponent is not 8 (for a non-DCTDecode/non- + /// /BitsPerComponent is not 1/2/4/8/16 (for a non-DCTDecode/non- /// CCITTFaxDecode image), when a CCITTFaxDecode image's resolved /// /ColorSpace has more than 1 component, or propagated from /// when /DecodeParms /K is 0 or greater /// (Group 3) or /EndOfLine is . /// private Surface DecodeImageXObject(PdfObject stream) + { + var surface = DecodeImageSamples(stream).Surface; + var softMaskEntry = stream.Get("SMask"); + if (softMaskEntry is null) + { + return surface; + } + + try + { + var softMask = Resolve(softMaskEntry); + if (softMask.Kind != PdfKind.Stream) + { + throw new InvalidDataException("Image XObject /SMask must be an image XObject stream."); + } + + if (GetNameValue(softMask, "Subtype") != "Image") + { + throw new InvalidDataException("Image XObject /SMask must be an image XObject (/Subtype /Image)."); + } + + // The soft-mask image is decoded through exactly the same sample pipeline (including + // JPXDecode and /Decode) but its own /SMask, if any, is never consulted (no nested masks). + var mask = DecodeImageSamples(softMask); + using var maskSurface = mask.Surface; + if (!mask.IsGray) + { + throw new InvalidDataException("Image XObject /SMask must be a single-component DeviceGray image."); + } + + ApplySoftMask(surface, maskSurface); + return surface; + } + catch + { + surface.Dispose(); + throw; + } + } + + /// + /// Multiplies the alpha channel of , in place, by the luminance + /// (red channel of the DeviceGray-decoded mask) of , resampling the + /// mask to the image's dimensions with nearest-neighbor sampling. + /// + private static void ApplySoftMask(Surface image, Surface mask) + { + for (var y = 0; y < image.Height; y++) + { + var my = (int)((long)y * mask.Height / image.Height); + for (var x = 0; x < image.Width; x++) + { + var mx = (int)((long)x * mask.Width / image.Width); + var pixel = image[x, y]; + var opacity = mask[mx, my].R; + image[x, y] = new Rgba32(pixel.R, pixel.G, pixel.B, (byte)(((pixel.A * opacity) + 127) / 255)); + } + } + } + + /// + /// A decoded image-sample surface together with whether it is a single-component, + /// non-/Indexed (gray) image - the only kind valid as a soft-mask image. + /// + private readonly record struct DecodedImage(Surface Surface, bool IsGray); + + /// + /// Decodes the sample data of an image XObject stream (everything + /// documents except /SMask handling), applying the + /// stream's /Decode array to the samples for every encoding. + /// + private DecodedImage DecodeImageSamples(PdfObject stream) { var width = RequireIntEntry(stream, "Width"); var height = RequireIntEntry(stream, "Height"); @@ -302,7 +387,19 @@ private Surface DecodeImageXObject(PdfObject stream) if (filterNames.Count == 1 && filterNames[0] == "DCTDecode") { var rawBytes = GetStreamRawBytes(stream); - return JpegCodec.Load(new MemoryStream(rawBytes)); + var channels = JpegCodec.GetInfo(new MemoryStream(rawBytes)).Channels; + + // JpegCodec only accepts 1-channel (gray) and 3-channel (YCbCr) JPEGs - a 4-channel + // (CMYK/YCCK) JPEG already fails closed in GetInfo above - and both decode to exactly + // their component samples, so the shared /Decode path applies exactly. + var jpegDecodeRanges = ResolveDecodeArray(stream, channels, indexed: false); + var jpegSurface = JpegCodec.Load(new MemoryStream(rawBytes)); + if (jpegDecodeRanges is not null) + { + ApplyDecodeToSurface(jpegSurface, channels, jpegDecodeRanges); + } + + return new DecodedImage(jpegSurface, channels == 1); } if (filterNames.Contains("DCTDecode")) @@ -310,6 +407,16 @@ private Surface DecodeImageXObject(PdfObject stream) throw new InvalidDataException("DCTDecode combined with another filter is not supported."); } + if (filterNames.Count == 1 && filterNames[0] == "JPXDecode") + { + return DecodeJpxImageXObject(stream); + } + + if (filterNames.Contains("JPXDecode")) + { + throw new InvalidDataException("JPXDecode combined with another filter is not supported."); + } + if (filterNames.Count == 1 && filterNames[0] == "CCITTFaxDecode") { return DecodeCcittFaxImageXObject(stream, filterParms[0], height); @@ -327,14 +434,38 @@ private Surface DecodeImageXObject(PdfObject stream) var colorSpace = ResolveColorSpaceValue(Resolve(colorSpaceObject)); var bitsPerComponent = RequireIntEntry(stream, "BitsPerComponent"); - if (bitsPerComponent != 8) + if (bitsPerComponent is not (1 or 2 or 4 or 8 or 16)) { throw new UnsupportedImageFeatureException( $"pdf-image-bitdepth-{bitsPerComponent}", - $"Image XObjects with {bitsPerComponent}-bit components are not supported; only 8 is supported."); + $"Image XObjects with {bitsPerComponent}-bit components are not supported; only 1, 2, 4, 8 and 16 are supported."); } var componentCount = ComponentCount(colorSpace); + var indexed = colorSpace.Kind == PdfColorSpace.Family.Indexed; + if (indexed && bitsPerComponent == 16) + { + throw new UnsupportedImageFeatureException( + "pdf-image-bitdepth-16", + "An /Indexed image with 16-bit indices is not supported."); + } + + // Normalize every depth to one byte per component (non-indexed samples scaled to 0-255, + // /Indexed samples kept as raw palette indices) so /Decode and color conversion share one path. + var samples = UnpackSamples(decoded, width, height, componentCount, bitsPerComponent, indexed); + var decodeRanges = ResolveDecodeArray(stream, componentCount, indexed, bitsPerComponent); + if (decodeRanges is not null) + { + if (bitsPerComponent == 8) + { + // UnpackSamples returns the (possibly cached) decoded stream bytes for 8-bit data; + // every other depth already produced a private buffer that can be modified in place. + samples = (byte[])samples.Clone(); + } + + ApplyDecodeArray(samples, componentCount, decodeRanges, indexed, (1 << bitsPerComponent) - 1); + } + var surface = new Surface(width, height); var rowBytes = componentCount * width; for (var y = 0; y < height; y++) @@ -343,11 +474,325 @@ private Surface DecodeImageXObject(PdfObject stream) for (var x = 0; x < width; x++) { var pixelOffset = rowOffset + (x * componentCount); - surface[x, y] = SamplesToColor(colorSpace, decoded, pixelOffset, componentCount); + surface[x, y] = SamplesToColor(colorSpace, samples, pixelOffset, componentCount); + } + } + + return new DecodedImage(surface, componentCount == 1 && !indexed); + } + + /// The decoder limits for JPX images: the renderer's maximum image dimensions. + private static readonly Jpeg2000DecoderLimits JpxDecoderLimits = new() + { + MaxWidth = Surface.MaxDimension, + MaxHeight = Surface.MaxDimension, + }; + + /// + /// Decodes a bare /Filter /JPXDecode image XObject (JPEG 2000) via + /// , trusting the decoded dimensions over + /// /Width//Height like DCTDecode. + /// + /// + /// When /ColorSpace is absent the JPEG 2000 data's own color space (gray, sRGB/sYCC, + /// CMYK, or by channel count) is used; when present it overrides it (device spaces, + /// /ICCBased by component count, and /Indexed, where the sample is the raw + /// palette index - which requires 8-bit index data, since samples are scaled to 8 bits) + /// and its component count must equal the decoded color channel count. An overriding + /// /ColorSpace is rejected when the JP2 data has its own palette (the decoder has + /// already expanded it), and an /Indexed one when the samples are not 8-bit. An optional + /// /Decode array maps each 8-bit sample linearly. /SMaskInData 1 uses + /// the codestream's opacity channel as alpha, 2 additionally un-premultiplies the + /// component samples (before /Decode and the color conversion; rejected for + /// /Indexed); both are ignored when the image has an explicit /SMask. The decode is + /// bounded by the renderer-wide maximum image dimension (Surface.MaxDimension) through + /// , so an oversized codestream is rejected before allocation; + /// the dictionary's /Width and /Height are not used as limits. + /// + /// + /// Thrown for malformed JPEG 2000 data, an invalid /Decode or /SMaskInData, + /// or a /ColorSpace whose component count disagrees with the decoded data. + /// + /// + /// Thrown for JPEG 2000 features the decoder does not support, an unsupported /ColorSpace, + /// a /ColorSpace override on palette-mapped JP2 data, or an /Indexed space over non-8-bit samples. + /// + private DecodedImage DecodeJpxImageXObject(PdfObject stream) + { + var smaskInData = GetIntEntry(stream, "SMaskInData", 0); + if (smaskInData is < 0 or > 2) + { + throw new InvalidDataException("Image XObject /SMaskInData must be 0, 1, or 2."); + } + + // Bound the decode explicitly by the renderer's own maximum image size: the codec checks + // the limits while parsing the header, before allocating any plane. The PDF /Width and + // /Height are deliberately not used as the limit - like DCTDecode, the decoder's own + // dimensions are trusted over a mismatching dictionary. + var jp2 = Jpeg2000Codec.Decode(GetStreamRawBytes(stream), JpxDecoderLimits); + + var colorSpaceObject = stream.Get("ColorSpace"); + PdfColorSpace colorSpace; + if (colorSpaceObject is null) + { + colorSpace = jp2.ColorSpace switch + { + Jpeg2000ColorSpace.Gray => PdfColorSpace.DeviceGray, + Jpeg2000ColorSpace.Srgb => PdfColorSpace.DeviceRGB, + Jpeg2000ColorSpace.Cmyk => PdfColorSpace.DeviceCMYK, + _ => jp2.ColorChannelCount switch + { + 1 => PdfColorSpace.DeviceGray, + 3 => PdfColorSpace.DeviceRGB, + 4 => PdfColorSpace.DeviceCMYK, + _ => throw new UnsupportedImageFeatureException( + "pdf-jpx-colorspace", + $"JPXDecode images with {jp2.ColorChannelCount} color channels need an explicit /ColorSpace."), + }, + }; + } + else + { + colorSpace = ResolveColorSpaceValue(Resolve(colorSpaceObject)); + if (colorSpace.Kind == PdfColorSpace.Family.Pattern) + { + throw new InvalidDataException("Image XObject /ColorSpace must not be /Pattern."); + } + } + + if (colorSpaceObject is not null && jp2.HasPalette) + { + throw new UnsupportedImageFeatureException( + "pdf-jpx-palette-colorspace", + "JPXDecode images whose JP2 data has a palette cannot be combined with a PDF /ColorSpace override."); + } + + if (colorSpace.Kind == PdfColorSpace.Family.Indexed && jp2.BitDepth != 8) + { + throw new UnsupportedImageFeatureException( + "pdf-jpx-indexed-bit-depth", + $"JPXDecode images with an /Indexed /ColorSpace require 8-bit samples, not {jp2.BitDepth}-bit."); + } + + var componentCount = ComponentCount(colorSpace); + if (componentCount != jp2.ColorChannelCount) + { + throw new InvalidDataException( + $"JPXDecode image has {jp2.ColorChannelCount} color channels but /ColorSpace has {componentCount} components."); + } + + var indexed = colorSpace.Kind == PdfColorSpace.Family.Indexed; + var samples = jp2.ColorSamples; + var useAlpha = smaskInData != 0 && jp2.AlphaSamples is not null && stream.Get("SMask") is null; + if (useAlpha && smaskInData == 2) + { + // /SMaskInData 2 means the color samples are premultiplied by opacity: undo that on + // the component samples, before /Decode and the color-space conversion, because the + // conversion (notably CMYK -> RGB) is not linear in the premultiplied values. + if (indexed) + { + throw new UnsupportedImageFeatureException( + "pdf-jpx-smaskindata-indexed", + "JPXDecode images with an /Indexed /ColorSpace cannot use /SMaskInData 2 (premultiplied indices)."); + } + + samples = UnpremultiplySamples(samples, jp2.AlphaSamples!, componentCount); + } + + var decodeRanges = ResolveDecodeArray(stream, componentCount, indexed); + if (decodeRanges is not null) + { + // samples is either the codec result (owned by this call) or an unpremultiplied copy. + ApplyDecodeArray(samples, componentCount, decodeRanges, indexed); + } + + var surface = new Surface(jp2.Width, jp2.Height); + var pixelCount = jp2.Width * jp2.Height; + for (var i = 0; i < pixelCount; i++) + { + var color = SamplesToColor(colorSpace, samples, i * componentCount, componentCount); + if (useAlpha) + { + color.A = jp2.AlphaSamples![i]; + } + + surface[i % jp2.Width, i / jp2.Width] = color; + } + + return new DecodedImage(surface, componentCount == 1 && !indexed); + } + + /// + /// Divides each interleaved 8-bit component sample by its pixel's opacity + /// (v' = min(255, v * 255 / alpha)), undoing premultiplication; fully transparent + /// pixels (alpha 0) are left unchanged. + /// + private static byte[] UnpremultiplySamples(byte[] samples, byte[] alpha, int componentCount) + { + var result = new byte[samples.Length]; + for (var i = 0; i < samples.Length; i++) + { + var a = alpha[i / componentCount]; + result[i] = a == 0 ? samples[i] : (byte)Math.Min(255, ((samples[i] * 255) + (a / 2)) / a); + } + + return result; + } + + /// + /// Resolves and validates an image /Decode array into 2 x components numbers + /// (Dmin, Dmax per component), shared by every image encoding. + /// + /// + /// The ranges, or when /Decode is absent or is the identity + /// mapping ([0 1] per component, or [0 255] for an /Indexed space). + /// + /// Thrown when the array is not 2 x components numbers. + private double[]? ResolveDecodeArray(PdfObject stream, int componentCount, bool indexed, int bitsPerComponent = 8) + { + var entry = stream.Get("Decode"); + if (entry is null) + { + return null; + } + + var decode = Resolve(entry); + if (decode.Kind != PdfKind.Array || decode.Items.Count != 2 * componentCount) + { + throw new InvalidDataException($"Image XObject /Decode must be an array of {2 * componentCount} numbers."); + } + + var ranges = new double[2 * componentCount]; + for (var i = 0; i < ranges.Length; i++) + { + var item = Resolve(decode.Items[i]); + if (item.Kind != PdfKind.Number) + { + throw new InvalidDataException("Image XObject /Decode must contain only numbers."); } + + ranges[i] = item.Number; + } + + var identityMax = indexed ? (double)((1 << bitsPerComponent) - 1) : 1.0; + var identity = true; + for (var c = 0; c < componentCount; c++) + { + identity &= Math.Abs(ranges[2 * c]) < 1e-9 && Math.Abs(ranges[(2 * c) + 1] - identityMax) < 1e-9; } - return surface; + return identity ? null : ranges; + } + + /// + /// Applies resolved /Decode ranges, in place, to interleaved 8-bit samples: + /// v' = Dmin + (v / 255) * (Dmax - Dmin) per component, re-quantized to 8 bits + /// (for a non-/Indexed space the decode range is [0, 1]-normalized; for + /// /Indexed it is in palette-index units). + /// + private static void ApplyDecodeArray( + byte[] samples, int componentCount, double[] ranges, bool indexed, int indexedMaxSample = 255) + { + var maxSample = indexed ? indexedMaxSample : 255; + for (var i = 0; i < samples.Length; i++) + { + var component = i % componentCount; + var min = ranges[2 * component]; + var max = ranges[(2 * component) + 1]; + var value = min + (samples[i] / (double)maxSample * (max - min)); + var scaled = indexed ? value : value * 255.0; + samples[i] = (byte)Math.Clamp((int)Math.Round(scaled), 0, 255); + } + } + + /// + /// Applies resolved /Decode ranges, in place, to the component samples of a decoded + /// 1-channel (gray) or 3-channel (RGB) (a JPEG whose decoder output is + /// exactly its component samples). + /// + private static void ApplyDecodeToSurface(Surface surface, int channels, double[] ranges) + { + var samples = new byte[surface.Width * surface.Height * channels]; + var index = 0; + for (var y = 0; y < surface.Height; y++) + { + for (var x = 0; x < surface.Width; x++) + { + var pixel = surface[x, y]; + samples[index++] = pixel.R; + if (channels == 3) + { + samples[index++] = pixel.G; + samples[index++] = pixel.B; + } + } + } + + ApplyDecodeArray(samples, channels, ranges, indexed: false); + index = 0; + for (var y = 0; y < surface.Height; y++) + { + for (var x = 0; x < surface.Width; x++) + { + var r = samples[index++]; + if (channels == 3) + { + var g = samples[index++]; + var b = samples[index++]; + surface[x, y] = new Rgba32(r, g, b, 255); + } + else + { + surface[x, y] = new Rgba32(r, r, r, 255); + } + } + } + } + + /// + /// Unpacks raw image sample data of any supported /BitsPerComponent (1, 2, 4, 8 or + /// 16; rows are padded to whole bytes) into one byte per component. Non-/Indexed + /// samples are scaled to the 0-255 range (16-bit samples keep their high byte); + /// /Indexed samples stay raw palette indices. + /// + /// Thrown when is shorter than the image needs. + private static byte[] UnpackSamples( + byte[] data, int width, int height, int componentCount, int bitsPerComponent, bool indexed) + { + var perRow = (long)width * componentCount; + var rowBytes = ((perRow * bitsPerComponent) + 7) / 8; + if (data.Length < rowBytes * height) + { + throw new InvalidDataException("Image XObject sample data is truncated."); + } + + if (bitsPerComponent == 8) + { + return data; + } + + var maxValue = (1 << bitsPerComponent) - 1; + var result = new byte[perRow * height]; + var o = 0; + for (var y = 0; y < height; y++) + { + var rowStart = y * rowBytes; + for (var i = 0L; i < perRow; i++) + { + if (bitsPerComponent == 16) + { + result[o++] = data[rowStart + (2 * i)]; + continue; + } + + var bitOffset = i * bitsPerComponent; + var shift = 8 - bitsPerComponent - (int)(bitOffset & 7); + var value = (data[rowStart + (bitOffset >> 3)] >> shift) & maxValue; + result[o++] = (byte)(indexed ? value : value * 255 / maxValue); + } + } + + return result; } /// @@ -371,7 +816,7 @@ private Surface DecodeImageXObject(PdfObject stream) /// from when /K is 0 or greater or /// /EndOfLine is . /// - private Surface DecodeCcittFaxImageXObject(PdfObject stream, PdfObject? parm, int imageHeight) + private DecodedImage DecodeCcittFaxImageXObject(PdfObject stream, PdfObject? parm, int imageHeight) { var rawBytes = GetStreamRawBytes(stream); var k = parm is null ? 0 : GetIntEntry(parm, "K", 0); @@ -409,6 +854,13 @@ private Surface DecodeCcittFaxImageXObject(PdfObject stream, PdfObject? parm, in "CCITTFaxDecode images require a single-component /ColorSpace."); } + var indexed = colorSpace.Kind == PdfColorSpace.Family.Indexed; + var decodeRanges = ResolveDecodeArray(stream, 1, indexed); + if (decodeRanges is not null) + { + ApplyDecodeArray(decoded, 1, decodeRanges, indexed); + } + var surface = new Surface(columns, rows); for (var y = 0; y < rows; y++) { @@ -419,7 +871,7 @@ private Surface DecodeCcittFaxImageXObject(PdfObject stream, PdfObject? parm, in } } - return surface; + return new DecodedImage(surface, !indexed); } /// diff --git a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs index c8218239..aaa7f558 100644 --- a/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs +++ b/src/DemaConsulting.CanvasNet.Pdf/PdfDocument.cs @@ -23,8 +23,9 @@ namespace DemaConsulting.CanvasNet.Pdf; /// device color (g/G/rg/RG/k/K/cs/ /// CS/sc/SC/scn/SCN), a generalized /Filter/ /// /DecodeParms stream-decoding pipeline, and image XObjects (Do: -/// DCTDecode via , or raw DeviceGray/ -/// DeviceRGB/DeviceCMYK 8-bit samples, composited through the current +/// DCTDecode via , JPXDecode via +/// , or raw DeviceGray/ +/// DeviceRGB/DeviceCMYK samples of 1, 2, 4, 8 or 16 bits per component, composited through the current /// transformation matrix). /// /// @@ -36,9 +37,11 @@ namespace DemaConsulting.CanvasNet.Pdf; /// two-dimensional only, decoded by - see /// PdfDocument.CcittFax.cs) are supported specifically for image XObjects; Group 3 /// (/DecodeParms /K zero or greater) and an explicit /EndOfLine true both -/// fail closed with ; and only -/// JPXDecode (and any other unrecognized filter name) remains entirely unsupported -/// for image XObjects. +/// fail closed with ; and +/// JPXDecode (JPEG 2000, decoded by , sole filter +/// only, honouring /ColorSpace, /Decode, /SMaskInData, and an explicit +/// /SMask image; never inline) is supported for image XObjects, while any other +/// unrecognized filter name remains entirely unsupported. /// /// /// Text rendering interprets the text object/state/positioning/showing operators diff --git a/src/DemaConsulting.CanvasNet/Codecs/ImageInfo.cs b/src/DemaConsulting.CanvasNet/Codecs/ImageInfo.cs index 60e02369..605664ce 100644 --- a/src/DemaConsulting.CanvasNet/Codecs/ImageInfo.cs +++ b/src/DemaConsulting.CanvasNet/Codecs/ImageInfo.cs @@ -11,9 +11,9 @@ namespace DemaConsulting.CanvasNet.Codecs; /// into a ; see this type's remarks for the full reasoning. /// /// -/// ImageInfo is a small, shared supporting data type used by all five raster codecs in +/// ImageInfo is a small, shared supporting data type used by all six codecs in /// this namespace (, , , -/// , and ) rather than being owned by any single +/// , , and ) rather than being owned by any single /// one of them, mirroring how /// is a shared supporting type for . It exists to /// let a caller inspect a file's declared width and height - and therefore estimate the @@ -79,7 +79,7 @@ namespace DemaConsulting.CanvasNet.Codecs; /// /// /// This type is a plain, immutable data carrier with no behavior beyond its record-struct -/// value equality; it deliberately has no new struct type per format, since all five raster +/// value equality; it deliberately has no new struct type per format, since all six /// codecs report the same four properties from their respective header formats: /// /// @@ -138,6 +138,15 @@ namespace DemaConsulting.CanvasNet.Codecs; /// carries (see 's remarks). /// /// +/// +/// +/// JPEG 2000: is the number of color channels the decoder +/// would return (after any JP2 palette or channel-definition box) plus one when +/// an opacity channel is present; is +/// only when the file declares an opacity channel (see +/// 's remarks). +/// +/// /// /// /// The image width, in pixels, as declared by the file's header. diff --git a/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Codestream.cs b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Codestream.cs new file mode 100644 index 00000000..810d9a13 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Codestream.cs @@ -0,0 +1,1259 @@ +namespace DemaConsulting.CanvasNet.Codecs; + +public static partial class Jpeg2000Codec +{ + // ================================================================================================ + // Codestream marker constants and decoder limits + // ================================================================================================ + + private const ushort MarkerSoc = 0xFF4F; + private const ushort MarkerSiz = 0xFF51; + private const ushort MarkerCod = 0xFF52; + private const ushort MarkerCoc = 0xFF53; + private const ushort MarkerCap = 0xFF50; + private const ushort MarkerQcd = 0xFF5C; + private const ushort MarkerQcc = 0xFF5D; + private const ushort MarkerRgn = 0xFF5E; + private const ushort MarkerPoc = 0xFF5F; + private const ushort MarkerPpm = 0xFF60; + private const ushort MarkerPpt = 0xFF61; + private const ushort MarkerTlm = 0xFF55; + private const ushort MarkerPlm = 0xFF57; + private const ushort MarkerPlt = 0xFF58; + private const ushort MarkerCrg = 0xFF63; + private const ushort MarkerSop = 0xFF91; + private const ushort MarkerSot = 0xFF90; + private const ushort MarkerSod = 0xFF93; + private const ushort MarkerEoc = 0xFFD9; + + /// The maximum number of image components the decoder accepts (more are rejected as unsupported). + internal const int MaxComponents = 16; + + /// The largest number of tiles the format can address (the SOT tile index is 16 bits wide). + private const int FormatMaxTiles = 65535; + + /// The largest number of decomposition levels the format allows. + private const int MaxDecompositionLevels = 32; + + /// + /// The largest number of magnitude bit-planes of one code-block the decoder handles. It is + /// validated once while the sub-band geometry is built (guard bits, exponent and ROI shift), so + /// every later stage can rely on it. + /// + internal const int MaxBitPlanes = 30; + + /// The progression-step allowance every decode gets regardless of the input length. + private const long ProgressionStepsBase = 1L << 22; + + /// The additional progression steps allowed per input byte (a packet costs at least one header bit). + private const long ProgressionStepsPerInputByte = 64; + + /// The entropy-decoding work (sample-passes) every decode gets regardless of the input length. + private const long Tier1WorkBase = 1L << 24; + + /// + /// The additional entropy-decoding work (sample-passes) allowed per input byte. The worst valid case measured + /// (a flat 16-bit plane) needs about 4,560 sample-passes per byte; 2^14 leaves a margin of about 3.6 while + /// keeping the CPU time a hostile stream can demand to roughly 55 microseconds per input byte. + /// + private const long Tier1WorkPerInputByte = 1L << 14; + + /// Creates the exception thrown for malformed JPEG 2000 data. + /// A description of what is malformed. + /// A new . + private static InvalidDataException Malformed(string message) => new("Invalid JPEG 2000 data: " + message); + + /// Creates the exception thrown for valid but unsupported JPEG 2000 features. + /// The stable machine-matchable feature token. + /// A description of the unsupported feature. + /// A new . + private static UnsupportedImageFeatureException Unsupported(string feature, string message) => + new(feature, "Unsupported JPEG 2000 feature: " + message); + + /// Computes ceil(a / b) for non-negative and positive . + /// The dividend. + /// The divisor. + /// The rounded-up quotient. + private static long CeilDiv(long a, long b) => (a + b - 1) / b; + + /// Computes ceil(a / 2^s) for non-negative . + /// The dividend. + /// The shift, 0 to 62. + /// The rounded-up quotient. + private static long CeilDivPow2(long a, int s) => (a + (1L << s) - 1) >> s; + + // ================================================================================================ + // Byte reader + // ================================================================================================ + + /// A bounds-checked big-endian reader over a window of a byte array. + internal sealed class ByteReader + { + private readonly byte[] _data; + private readonly int _end; + + /// Initializes a new instance of the class. + /// The backing array. + /// The first readable offset. + /// The exclusive end offset. + public ByteReader(byte[] data, int start, int end) + { + if (start < 0 || end < start || end > data.Length) + { + throw Malformed("segment bounds are outside the data."); + } + + _data = data; + Position = start; + _end = end; + } + + /// Gets or sets the current absolute offset. + public int Position { get; set; } + + /// Gets the number of unread bytes. + public int Remaining => _end - Position; + + /// Requires that every byte of the window has been consumed. + /// The name of the structure being parsed, for the error message. + public void RequireEnd(string what) + { + if (Remaining != 0) + { + throw Malformed($"{what} has {Remaining} unexpected trailing byte(s)."); + } + } + + /// Reads one byte. + /// The byte value. + public int ReadU8() + { + if (Position >= _end) + { + throw Malformed("unexpected end of data."); + } + + return _data[Position++]; + } + + /// Reads a big-endian 16-bit value. + /// The value. + public int ReadU16() + { + var hi = ReadU8(); + return (hi << 8) | ReadU8(); + } + + /// Reads a big-endian 32-bit value. + /// The value. + public long ReadU32() + { + var hi = (long)ReadU16(); + return (hi << 16) | (uint)ReadU16(); + } + + /// Reads a big-endian 64-bit value, rejecting values above . + /// The value. + public long ReadU64() + { + var hi = ReadU32(); + var value = (hi << 32) | ReadU32(); + if (value < 0) + { + throw Malformed("64-bit value is out of range."); + } + + return value; + } + + /// Copies bytes out and advances. + /// The number of bytes. + /// A new array holding the bytes. + public byte[] ReadBytes(int count) + { + if (count < 0 || count > Remaining) + { + throw Malformed("unexpected end of data."); + } + + var result = new byte[count]; + Array.Copy(_data, Position, result, 0, count); + Position += count; + return result; + } + } + + // ================================================================================================ + // SIZ and coding parameter model + // ================================================================================================ + + /// The decoded SIZ (image and tile size) marker segment. + internal sealed class SizInfo + { + /// Gets the reference-grid width (right edge). + public long Xsiz { get; init; } + + /// Gets the reference-grid height (bottom edge). + public long Ysiz { get; init; } + + /// Gets the horizontal image offset. + public long XOsiz { get; init; } + + /// Gets the vertical image offset. + public long YOsiz { get; init; } + + /// Gets the tile width. + public long XTsiz { get; init; } + + /// Gets the tile height. + public long YTsiz { get; init; } + + /// Gets the horizontal tile grid offset. + public long XTOsiz { get; init; } + + /// Gets the vertical tile grid offset. + public long YTOsiz { get; init; } + + /// Gets the per-component bit depth (1 to 16 accepted). + public required int[] Depth { get; init; } + + /// Gets the per-component signedness. + public required bool[] Signed { get; init; } + + /// Gets the per-component horizontal subsampling. + public required int[] XR { get; init; } + + /// Gets the per-component vertical subsampling. + public required int[] YR { get; init; } + + /// Gets the number of components. + public int Csiz => Depth.Length; + + /// Gets the image width in reference-grid samples. + public long Width => Xsiz - XOsiz; + + /// Gets the image height in reference-grid samples. + public long Height => Ysiz - YOsiz; + + /// Gets the number of tile columns. + public int NumXTiles => (int)CeilDiv(Xsiz - XTOsiz, XTsiz); + + /// Gets the number of tile rows. + public int NumYTiles => (int)CeilDiv(Ysiz - YTOsiz, YTsiz); + + /// Gets the left edge of component in component samples. + /// The component index. + /// The component-grid coordinate. + public long CompX0(int c) => CeilDiv(XOsiz, XR[c]); + + /// Gets the top edge of component in component samples. + /// The component index. + /// The component-grid coordinate. + public long CompY0(int c) => CeilDiv(YOsiz, YR[c]); + + /// Gets the right edge of component in component samples. + /// The component index. + /// The component-grid coordinate. + public long CompX1(int c) => CeilDiv(Xsiz, XR[c]); + + /// Gets the bottom edge of component in component samples. + /// The component index. + /// The component-grid coordinate. + public long CompY1(int c) => CeilDiv(Ysiz, YR[c]); + + /// + /// Checks the declared image against the resource limits. This runs on the SIZ values alone, + /// before any plane, tile or code-block structure is allocated. + /// + /// The limits to enforce. + public void CheckLimits(Jpeg2000DecoderLimits limits) + { + if (Width > limits.MaxWidth || Height > limits.MaxHeight) + { + throw Malformed($"image dimensions {Width}x{Height} exceed the {limits.MaxWidth}x{limits.MaxHeight} pixel limit."); + } + + var tilesX = CeilDiv(Xsiz - XTOsiz, XTsiz); + var tilesY = CeilDiv(Ysiz - YTOsiz, YTsiz); + if (tilesX > limits.MaxTiles || tilesY > limits.MaxTiles || tilesX > limits.MaxTiles / tilesY) + { + throw Malformed("too many tiles."); + } + + // Width and Height are within int range here, so each plane (at most Width x Height plus a + // rounding sample per axis) fits comfortably in a long. + long planeSamples = 0; + for (var c = 0; c < Csiz; c++) + { + planeSamples += (CompX1(c) - CompX0(c)) * (CompY1(c) - CompY0(c)); + if (planeSamples > limits.MaxTotalSamples) + { + throw Malformed("image exceeds the decoder sample limit."); + } + } + } + + /// Validates the component count and that the remaining SIZ payload holds exactly one entry per component. + /// The component count. + /// The remaining payload bytes. + private static void CheckComponentCount(int csiz, int remaining) + { + if (csiz < 1 || csiz > 16384) + { + throw Malformed("invalid component count."); + } + + if (csiz > MaxComponents) + { + throw Unsupported("jpeg2000-component-count", "more than " + MaxComponents + " components."); + } + + if (remaining != csiz * 3) + { + throw Malformed("SIZ segment length does not match the component count."); + } + } + + /// Parses and validates a SIZ marker segment payload. + /// A reader positioned just after the segment length. + /// The validated SIZ information. + public static SizInfo Parse(ByteReader r) + { + // Rsiz capability bits: bit 14 flags HTJ2K and bit 15 flags Part 2 extensions. + var rsiz = r.ReadU16(); + if ((rsiz & 0x8000) != 0 || (rsiz & 0x4000) != 0) + { + throw Unsupported("jpeg2000-extensions", "Part 2 / high-throughput capabilities are not supported."); + } + + var xsiz = r.ReadU32(); + var ysiz = r.ReadU32(); + var xo = r.ReadU32(); + var yo = r.ReadU32(); + var xt = r.ReadU32(); + var yt = r.ReadU32(); + var xto = r.ReadU32(); + var yto = r.ReadU32(); + var csiz = r.ReadU16(); + + // Geometry invariants from ITU-T T.800 Table A.9. + if (xsiz == 0 || ysiz == 0 || xt == 0 || yt == 0 || xo >= xsiz || yo >= ysiz + || xto > xo || yto > yo || xto + xt <= xo || yto + yt <= yo) + { + throw Malformed("inconsistent image or tile size."); + } + + CheckComponentCount(csiz, r.Remaining); + + var depth = new int[csiz]; + var signed = new bool[csiz]; + var xr = new int[csiz]; + var yr = new int[csiz]; + for (var c = 0; c < csiz; c++) + { + var ssiz = r.ReadU8(); + depth[c] = (ssiz & 0x7F) + 1; + signed[c] = (ssiz & 0x80) != 0; + xr[c] = r.ReadU8(); + yr[c] = r.ReadU8(); + if (xr[c] == 0 || yr[c] == 0) + { + throw Malformed("component subsampling must not be zero."); + } + + if (depth[c] > 16) + { + throw Unsupported("jpeg2000-bit-depth", "component bit depth above 16."); + } + } + + // The tile count is bounded here, before any per-tile structure is created. + // Each axis is range-checked first, then the product is checked by division, so no + // intermediate value can wrap and nothing is cast before it is known to be small. + var tilesX = CeilDiv(xsiz - xto, xt); + var tilesY = CeilDiv(ysiz - yto, yt); + if (tilesX > FormatMaxTiles || tilesY > FormatMaxTiles || tilesX > FormatMaxTiles / tilesY) + { + throw Malformed("too many tiles."); + } + + return new SizInfo + { + Xsiz = xsiz, + Ysiz = ysiz, + XOsiz = xo, + YOsiz = yo, + XTsiz = xt, + YTsiz = yt, + XTOsiz = xto, + YTOsiz = yto, + Depth = depth, + Signed = signed, + XR = xr, + YR = yr, + }; + } + } + + /// The per-component coding style (COD/COC) parameters. + internal sealed class CodingParams + { + /// Gets the number of decomposition levels. + public int Levels { get; init; } + + /// Gets the code-block width exponent. + public int Xcb { get; init; } + + /// Gets the code-block height exponent. + public int Ycb { get; init; } + + /// Gets the code-block style flags. + public int Style { get; init; } + + /// Gets a value indicating whether the reversible 5/3 wavelet is used. + public bool Reversible { get; init; } + + /// Gets the precinct width exponents per resolution. + public required int[] PPx { get; init; } + + /// Gets the precinct height exponents per resolution. + public required int[] PPy { get; init; } + + /// Parses the SPcod/SPcoc parameter block. + /// The reader positioned at the block. + /// Whether explicit precinct sizes follow. + /// The parsed parameters. + public static CodingParams Parse(ByteReader r, bool hasPrecincts) + { + var levels = r.ReadU8(); + var xcb = r.ReadU8() + 2; + var ycb = r.ReadU8() + 2; + var style = r.ReadU8(); + var transform = r.ReadU8(); + if (levels > MaxDecompositionLevels) + { + throw Malformed("too many decomposition levels."); + } + + if (xcb > 10 || ycb > 10 || xcb + ycb > 12) + { + throw Malformed("invalid code-block size."); + } + + if ((style & 0xC0) != 0) + { + throw Unsupported("jpeg2000-codeblock-style", "high-throughput or Part 2 code-block style."); + } + + if (transform > 1) + { + throw Unsupported("jpeg2000-transform", "wavelet transform other than 5/3 or 9/7."); + } + + var ppx = new int[levels + 1]; + var ppy = new int[levels + 1]; + for (var i = 0; i <= levels; i++) + { + if (hasPrecincts) + { + var b = r.ReadU8(); + ppx[i] = b & 0xF; + ppy[i] = b >> 4; + + // A precinct dimension of 1 sample is only meaningful at the lowest resolution. + if (i > 0 && (ppx[i] == 0 || ppy[i] == 0)) + { + throw Malformed("invalid precinct size."); + } + } + else + { + ppx[i] = 15; + ppy[i] = 15; + } + } + + return new CodingParams + { + Levels = levels, + Xcb = xcb, + Ycb = ycb, + Style = style, + Reversible = transform == 1, + PPx = ppx, + PPy = ppy, + }; + } + } + + /// The per-component quantization (QCD/QCC) parameters. + internal sealed class QuantParams + { + /// Gets the quantization style: 0 none, 1 scalar derived, 2 scalar expounded. + public int Style { get; init; } + + /// Gets the number of guard bits. + public int GuardBits { get; init; } + + /// Gets the sub-band exponents. + public required int[] Exp { get; init; } + + /// Gets the sub-band mantissas. + public required int[] Mant { get; init; } + + /// Parses a QCD/QCC style byte and its sub-band entries. + /// The reader positioned at the style byte. + /// The parsed parameters. + public static QuantParams Parse(ByteReader r) + { + var sq = r.ReadU8(); + var style = sq & 0x1F; + var guard = sq >> 5; + if (style > 2) + { + throw Malformed("invalid quantization style."); + } + + int[] exp; + int[] mant; + if (style == 0) + { + var n = r.Remaining; + exp = new int[n]; + mant = new int[n]; + for (var i = 0; i < n; i++) + { + exp[i] = r.ReadU8() >> 3; + } + } + else if (style == 1) + { + var v = r.ReadU16(); + exp = [v >> 11]; + mant = [v & 0x7FF]; + r.RequireEnd("quantization segment"); + } + else + { + if (r.Remaining % 2 != 0) + { + throw Malformed("expounded quantization segment has an odd payload length."); + } + + var n = r.Remaining / 2; + exp = new int[n]; + mant = new int[n]; + for (var i = 0; i < n; i++) + { + var v = r.ReadU16(); + exp[i] = v >> 11; + mant[i] = v & 0x7FF; + } + } + + if (exp.Length == 0) + { + throw Malformed("quantization segment has no entries."); + } + + return new QuantParams { Style = style, GuardBits = guard, Exp = exp, Mant = mant }; + } + } + + /// One progression order change (POC) entry. + /// The first resolution of the volume. + /// The first component of the volume. + /// The exclusive layer end. + /// The exclusive resolution end. + /// The exclusive component end. + /// The progression order, 0 to 4. + internal readonly record struct ProgressionChange(int ResStart, int CompStart, int LayerEnd, int ResEnd, int CompEnd, int Order); + + /// + /// The mutable coding state in effect for the main header or for a tile: the COD/COC, QCD/QCC, + /// RGN and POC parameters with the marker-precedence rules of ITU-T T.800 A.6. + /// + internal sealed class CodingState + { + private readonly int _components; + private readonly int _maxPoc; + private readonly bool[] _cocSet; + private readonly bool[] _qccSet; + + /// Initializes an empty state for components. + /// The component count. + /// The maximum number of progression-order change entries the state may hold. + public CodingState(int components, int maxPocEntries) + { + _components = components; + _maxPoc = maxPocEntries; + _cocSet = new bool[components]; + _qccSet = new bool[components]; + Coding = new CodingParams?[components]; + Quant = new QuantParams?[components]; + RoiShift = new int[components]; + } + + /// Gets the progression order. + public int Progression { get; private set; } + + /// Gets the number of layers. + public int Layers { get; private set; } = 1; + + /// Gets a value indicating whether the multiple component transform is used. + public bool Mct { get; private set; } + + /// Gets a value indicating whether SOP markers may be present. + public bool Sop { get; private set; } + + /// Gets a value indicating whether EPH markers are present. + public bool Eph { get; private set; } + + /// Gets a value indicating whether a COD marker has been seen. + public bool HasCod { get; private set; } + + /// Gets a value indicating whether a QCD marker has been seen. + public bool HasQcd { get; private set; } + + /// Gets the per-component coding parameters. + public CodingParams?[] Coding { get; } + + /// Gets the per-component quantization parameters. + public QuantParams?[] Quant { get; } + + /// Gets the per-component ROI max-shift values. + public int[] RoiShift { get; } + + /// Gets or sets the POC entries, or when none were signalled. + public List? Poc { get; set; } + + private bool _pocInherited; + + /// Creates a copy used as the starting state of a tile. + /// The copy; main-header POC entries are inherited until the tile-part header signals its own. + public CodingState CloneForTile() + { + var copy = new CodingState(_components, _maxPoc) + { + Progression = Progression, + Layers = Layers, + Mct = Mct, + Sop = Sop, + Eph = Eph, + HasCod = HasCod, + HasQcd = HasQcd, + }; + Array.Copy(Coding, copy.Coding, _components); + Array.Copy(Quant, copy.Quant, _components); + Array.Copy(RoiShift, copy.RoiShift, _components); + if (Poc is not null) + { + copy.Poc = [.. Poc]; + copy._pocInherited = true; + } + + return copy; + } + + /// Applies a COD segment. + /// The segment payload reader. + public void ApplyCod(ByteReader r) + { + var scod = r.ReadU8(); + var prog = r.ReadU8(); + var layers = r.ReadU16(); + var mct = r.ReadU8(); + if (prog > 4) + { + throw Malformed("invalid progression order."); + } + + if (layers < 1) + { + throw Malformed("layer count must be at least 1."); + } + + if (mct > 1) + { + throw Unsupported("jpeg2000-mct", "multiple component transform other than the Part 1 transforms."); + } + + var p = CodingParams.Parse(r, (scod & 1) != 0); + r.RequireEnd("COD segment"); + Progression = prog; + Layers = layers; + Mct = mct == 1; + Sop = (scod & 2) != 0; + Eph = (scod & 4) != 0; + HasCod = true; + for (var c = 0; c < _components; c++) + { + if (!_cocSet[c]) + { + Coding[c] = p; + } + } + } + + /// Applies a COC segment. + /// The segment payload reader. + public void ApplyCoc(ByteReader r) + { + var c = r.ReadU8(); + var scoc = r.ReadU8(); + if (c >= _components) + { + throw Malformed("COC component index out of range."); + } + + Coding[c] = CodingParams.Parse(r, (scoc & 1) != 0); + r.RequireEnd("COC segment"); + _cocSet[c] = true; + } + + /// Applies a QCD segment. + /// The segment payload reader. + public void ApplyQcd(ByteReader r) + { + var q = QuantParams.Parse(r); + HasQcd = true; + for (var c = 0; c < _components; c++) + { + if (!_qccSet[c]) + { + Quant[c] = q; + } + } + } + + /// Applies a QCC segment. + /// The segment payload reader. + public void ApplyQcc(ByteReader r) + { + var c = r.ReadU8(); + if (c >= _components) + { + throw Malformed("QCC component index out of range."); + } + + Quant[c] = QuantParams.Parse(r); + _qccSet[c] = true; + } + + /// Applies an RGN (region of interest) segment. + /// The segment payload reader. + public void ApplyRgn(ByteReader r) + { + var c = r.ReadU8(); + var style = r.ReadU8(); + var shift = r.ReadU8(); + if (c >= _components) + { + throw Malformed("RGN component index out of range."); + } + + if (style != 0) + { + throw Unsupported("jpeg2000-roi", "ROI style other than implicit max-shift."); + } + + if (shift > 37) + { + throw Malformed("ROI shift is out of range."); + } + + r.RequireEnd("RGN segment"); + RoiShift[c] = shift; + } + + /// Appends the entries of a POC segment. + /// The segment payload reader. + public void ApplyPoc(ByteReader r) + { + if (_pocInherited || Poc is null) + { + // A tile-part POC replaces any entries inherited from the main header. + Poc = []; + _pocInherited = false; + } + + if (r.Remaining == 0 || r.Remaining % 7 != 0) + { + throw Malformed("invalid POC segment length."); + } + + while (r.Remaining > 0) + { + var rs = r.ReadU8(); + var cs = r.ReadU8(); + var lye = r.ReadU16(); + var re = r.ReadU8(); + var ce = r.ReadU8(); + var order = r.ReadU8(); + if (ce == 0) + { + ce = 256; + } + + if (order > 4 || rs >= re || cs >= ce || lye < 1) + { + throw Malformed("invalid POC entry."); + } + + if (Poc.Count >= _maxPoc) + { + throw Malformed("too many progression order changes."); + } + + Poc.Add(new ProgressionChange(rs, cs, lye, re, ce, order)); + } + } + } + + // ================================================================================================ + // Codestream container + // ================================================================================================ + + /// The data collected for one tile from all of its tile-parts. + internal sealed class TileData + { + /// Gets or sets the tile-specific coding state, or before the first tile-part. + public CodingState? State { get; set; } + + /// Gets the concatenated tile-part bodies. + public MemoryStream Body { get; private set; } = new(); + + /// Gets the concatenated packed packet headers (from PPM/PPT). + public MemoryStream PackedHeaders { get; private set; } = new(); + + /// Drops the buffered tile-part bodies and packed headers so they can be collected. + public void Release() + { + Body = new MemoryStream(0); + PackedHeaders = new MemoryStream(0); + } + + /// Gets or sets a value indicating whether packed packet headers are in use. + public bool UsesPackedHeaders { get; set; } + + /// Gets or sets the number of tile-parts read so far. + public int Parts { get; set; } + } + + /// A parsed JPEG 2000 codestream: main header parameters and per-tile data. + internal sealed class Codestream + { + /// Gets the image and tile size information. + public required SizInfo Siz { get; init; } + + /// Gets the main-header coding state. + public required CodingState Main { get; init; } + + /// Gets the per-tile data, indexed by tile number; every tile has at least one tile-part. + public required TileData[] Tiles { get; init; } + + /// Reads only the SIZ segment of a codestream. + /// The buffer holding the codestream. + /// The offset of the SOC marker. + /// The exclusive end offset of the codestream. + /// Receives the offset just past the SIZ marker segment. + /// The validated SIZ information. + public static SizInfo ParseSizOnly(byte[] data, int offset, int end, out int afterSiz) + { + var r = new ByteReader(data, offset, end); + if (r.ReadU16() != MarkerSoc) + { + throw Malformed("codestream does not start with SOC."); + } + + if (r.ReadU16() != MarkerSiz) + { + throw Malformed("SIZ marker segment must follow SOC."); + } + + var len = r.ReadU16(); + if (len < 41) + { + throw Malformed("invalid SIZ length."); + } + + afterSiz = SegmentEnd(r, len); + return SizInfo.Parse(new ByteReader(data, r.Position, afterSiz)); + } + + /// Parses the main header and splits the tile-parts per tile. + /// The buffer holding the codestream. + /// The offset of the SOC marker. + /// The exclusive end offset of the codestream. + /// The resource limits; the image size is checked against them before anything is allocated. + /// The parsed codestream. + public static Codestream Parse(byte[] data, int offset, int end, Jpeg2000DecoderLimits limits) + { + var siz = ParseSizOnly(data, offset, end, out var afterSiz); + siz.CheckLimits(limits); + var r = new ByteReader(data, afterSiz, end); + var main = new CodingState(siz.Csiz, limits.MaxProgressionChanges); + var ppmSegments = new List<(int Index, byte[] Data)>(); + ReadMainHeader(data, r, main, ppmSegments); + + if (!main.HasCod || !main.HasQcd) + { + throw Malformed("main header lacks a COD or QCD marker segment."); + } + + for (var c = 0; c < siz.Csiz; c++) + { + if (main.Coding[c] is null || main.Quant[c] is null) + { + throw Malformed("component lacks coding or quantization parameters."); + } + } + + var tileSlots = new TileData?[siz.NumXTiles * siz.NumYTiles]; + var ppmChunks = ppmSegments.Count > 0 ? SplitPpm(ppmSegments) : null; + ReadTileParts(data, r, end, main, tileSlots, ppmChunks); + + // Fail closed: a codestream cut short or lacking whole tiles must not decode to a partial image. + var tiles = new TileData[tileSlots.Length]; + for (var i = 0; i < tiles.Length; i++) + { + tiles[i] = tileSlots[i] ?? throw Malformed($"tile {i} has no tile-part (the codestream is incomplete)."); + } + + return new Codestream { Siz = siz, Main = main, Tiles = tiles }; + } + + /// Main header: processes marker segments until the first SOT, which is left unread. + private static void ReadMainHeader(byte[] data, ByteReader r, CodingState main, List<(int Index, byte[] Data)> ppmSegments) + { + while (true) + { + var marker = r.ReadU16(); + if (marker == MarkerSot) + { + r.Position -= 2; + break; + } + + if (marker == MarkerEoc || marker == MarkerSod || marker == MarkerSoc || marker < 0xFF00) + { + throw Malformed("unexpected marker in main header."); + } + + if (marker is >= 0xFF30 and <= 0xFF3F) + { + continue; + } + + var len = r.ReadU16(); + if (len < 2) + { + throw Malformed("invalid marker segment length."); + } + + var segEnd = SegmentEnd(r, len); + var seg = new ByteReader(data, r.Position, segEnd); + ApplyMainMarker(marker, seg, main, ppmSegments); + r.Position = segEnd; + } + } + + private static void ApplyMainMarker(int marker, ByteReader seg, CodingState main, List<(int Index, byte[] Data)> ppmSegments) + { + switch (marker) + { + case MarkerCod: + main.ApplyCod(seg); + break; + case MarkerCoc: + main.ApplyCoc(seg); + break; + case MarkerQcd: + main.ApplyQcd(seg); + break; + case MarkerQcc: + main.ApplyQcc(seg); + break; + case MarkerRgn: + main.ApplyRgn(seg); + break; + case MarkerPoc: + main.ApplyPoc(seg); + break; + case MarkerCap: + throw Unsupported("jpeg2000-extensions", "high-throughput (CAP) codestream."); + case MarkerPpm: + AddPackedHeaderSegment(ppmSegments, seg, "PPM"); + break; + case MarkerSiz: + throw Malformed("duplicate SIZ marker."); + case MarkerPpt: + case MarkerPlt: + case MarkerSop: + // Tile-part header markers (T.800 A.7.6, A.7.3, A.8.1); OpenJPEG rejects them here as well. + throw Malformed("marker not allowed in the main header."); + default: + // TLM, PLM, CRG, COM and unknown segments carry nothing the decoder needs. + break; + } + } + + /// + /// Collects one PPM/PPT segment under its index (PPM/PPT index). A repeated index is rejected (OpenJPEG does the + /// same); gaps in the sequence are tolerated, as the segments are simply concatenated in index order. + /// + private static void AddPackedHeaderSegment(List<(int Index, byte[] Data)> segments, ByteReader seg, string what) + { + var index = seg.ReadU8(); + if (segments.Exists(s => s.Index == index)) + { + throw Malformed($"duplicate {what} segment index."); + } + + segments.Add((index, seg.ReadBytes(seg.Remaining))); + } + + private static int SegmentEnd(ByteReader r, int length) + { + // The length field counts itself; the reader has already consumed it. + var segEnd = r.Position + length - 2; + if (length < 2 || length - 2 > r.Remaining) + { + throw Malformed("marker segment extends beyond the data."); + } + + return segEnd; + } + + private static List SplitPpm(List<(int Index, byte[] Data)> segments) + { + // Zppm orders the segments; the concatenation is a sequence of (Nppm, Ippm) tile-part chunks. + segments.Sort((a, b) => a.Index.CompareTo(b.Index)); + using var all = new MemoryStream(); + foreach (var data in segments.Select(s => s.Data)) + { + all.Write(data, 0, data.Length); + } + + var buffer = all.ToArray(); + var chunks = new List(); + var pos = 0; + while (pos < buffer.Length) + { + if (buffer.Length - pos < 4) + { + throw Malformed("PPM data is truncated."); + } + + var n = ((long)buffer[pos] << 24) | ((long)buffer[pos + 1] << 16) | ((long)buffer[pos + 2] << 8) | buffer[pos + 3]; + pos += 4; + if (n > buffer.Length - pos) + { + throw Malformed("PPM chunk extends beyond the data."); + } + + var chunk = new byte[n]; + Array.Copy(buffer, pos, chunk, 0, (int)n); + chunks.Add(chunk); + pos += (int)n; + } + + return chunks; + } + + /// Computes the end of a tile-part. Psot of zero means the last tile-part extends to the end of the codestream (minus a trailing EOC). + private static long TilePartEnd(byte[] data, int sotPos, long psot, int end) + { + long partEnd = sotPos + psot; + if (psot == 0) + { + partEnd = end; + if (end - sotPos >= 16 && data[end - 2] == 0xFF && data[end - 1] == 0xD9) + { + partEnd = end - 2; + } + } + else if (psot < 14 || partEnd > end) + { + throw Malformed("tile-part length extends beyond the data."); + } + + return partEnd; + } + + private static void ReadTileParts(byte[] data, ByteReader r, int end, CodingState main, TileData?[] tiles, List? ppm) + { + var partCount = 0; + while (r.Remaining >= 2) + { + // A missing EOC is tolerated; anything else after a tile-part must be a SOT or EOC. + var sotPos = r.Position; + var marker = r.ReadU16(); + if (marker == MarkerEoc) + { + break; + } + + if (marker != MarkerSot) + { + throw Malformed("expected SOT marker."); + } + + var lsot = r.ReadU16(); + var isot = r.ReadU16(); + var psot = r.ReadU32(); + var tpsot = r.ReadU8(); + r.ReadU8(); // TNsot is advisory and not enforced. + if (lsot != 10 || isot >= tiles.Length) + { + throw Malformed("invalid SOT marker segment."); + } + + var partEnd = TilePartEnd(data, sotPos, psot, end); + var tile = tiles[isot] ??= new TileData(); + CheckTilePartSequence(tile, tpsot); + tile.State ??= main.CloneForTile(); + var bodyStart = ReadTilePartHeader(data, r.Position, (int)partEnd, tile, partCount, ppm); + tile.Body.Write(data, bodyStart, (int)partEnd - bodyStart); + r.Position = (int)partEnd; + partCount++; + if (psot == 0) + { + break; + } + } + + if (ppm is not null && partCount > ppm.Count) + { + throw Malformed("PPM data does not cover every tile-part."); + } + } + + /// + /// Checks the tile-part index (TPsot) of the next tile-part of a tile: the tile-parts of a tile must arrive in + /// order. The tile-part count (TNsot) is advisory and deliberately not enforced: real encoders write it + /// incorrectly (for example one too small) and the decoder, which concatenates the tile-parts it finds, + /// does not depend on it. + /// + /// The tile the tile-part belongs to. + /// The tile-part index. + private static void CheckTilePartSequence(TileData tile, int tpsot) + { + if (tpsot != tile.Parts) + { + throw Malformed("tile-part index is out of sequence."); + } + + tile.Parts++; + } + + private static int ReadTilePartHeader(byte[] data, int start, int partEnd, TileData tile, int partIndex, List? ppm) + { + var state = tile.State!; + var pptSegments = new List<(int Index, byte[] Data)>(); + var rr = new ByteReader(data, start, partEnd); + while (true) + { + var marker = rr.ReadU16(); + if (marker == MarkerSod) + { + break; + } + + if (marker < 0xFF00 || marker == MarkerSot || marker == MarkerEoc) + { + throw Malformed("unexpected marker in tile-part header."); + } + + if (marker is >= 0xFF30 and <= 0xFF3F) + { + continue; + } + + var len = rr.ReadU16(); + if (len < 2) + { + throw Malformed("invalid marker segment length."); + } + + var segEnd = SegmentEnd(rr, len); + // CheckTilePartSequence has already counted this part, so Parts > 1 means TPsot > 0 (per tile, not global). + ApplyTileMarker(marker, new ByteReader(data, rr.Position, segEnd), state, pptSegments, tile.Parts > 1); + rr.Position = segEnd; + } + + AddPackedHeaders(tile, partIndex, ppm, pptSegments); + return rr.Position; + } + + /// Appends the PPM (main header) and PPT (tile-part header) packed packet headers of a tile-part to the tile. + private static void AddPackedHeaders(TileData tile, int partIndex, List? ppm, List<(int Index, byte[] Data)> pptSegments) + { + if (ppm is not null) + { + tile.UsesPackedHeaders = true; + if (partIndex < ppm.Count) + { + tile.PackedHeaders.Write(ppm[partIndex], 0, ppm[partIndex].Length); + } + } + + if (pptSegments.Count > 0) + { + tile.UsesPackedHeaders = true; + pptSegments.Sort((a, b) => a.Index.CompareTo(b.Index)); + foreach (var chunk in pptSegments.Select(s => s.Data)) + { + tile.PackedHeaders.Write(chunk, 0, chunk.Length); + } + } + } + + private static void ApplyTileMarker(int marker, ByteReader seg, CodingState state, List<(int Index, byte[] Data)> pptSegments, bool laterPart) + { + // ISO 15444-1 A.6.1-A.6.4 / A.4.2: COD, COC, QCD, QCC and RGN are allowed only in the first tile-part + // header of a tile (the tile-parts share one coding state, so a later override would apply retroactively). + // POC and PPT are allowed in any tile-part. + if (laterPart && marker is MarkerCod or MarkerCoc or MarkerQcd or MarkerQcc or MarkerRgn) + { + throw Malformed("marker only allowed in the first tile-part header of a tile."); + } + + switch (marker) + { + case MarkerCod: + state.ApplyCod(seg); + break; + case MarkerCoc: + state.ApplyCoc(seg); + break; + case MarkerQcd: + state.ApplyQcd(seg); + break; + case MarkerQcc: + state.ApplyQcc(seg); + break; + case MarkerRgn: + state.ApplyRgn(seg); + break; + case MarkerPoc: + state.ApplyPoc(seg); + break; + case MarkerPpt: + AddPackedHeaderSegment(pptSegments, seg, "PPT"); + break; + case MarkerPpm: + case MarkerSiz: + case MarkerCap: + case MarkerTlm: + case MarkerPlm: + case MarkerCrg: + case MarkerSop: + throw Malformed("marker not allowed in a tile-part header."); + default: + // PLT, COM and unknown segments carry nothing the decoder needs. + break; + } + } + } +} diff --git a/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Decoder.cs b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Decoder.cs new file mode 100644 index 00000000..df17398c --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Decoder.cs @@ -0,0 +1,383 @@ +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Codecs; + +public static partial class Jpeg2000Codec +{ + // ================================================================================================ + // Decoding orchestration: tiles -> tier-2 -> tier-1 -> inverse DWT -> inverse MCT -> sample planes -> 8-bit image + // ================================================================================================ + + /// + /// The single work budget of one decode. It is created by the decoder and shared by every tile and + /// every progression change, so the cumulative work is bounded no matter how the hostile input is split. + /// + internal sealed class DecodeBudget + { + private readonly long _maxProgression; + private readonly long _maxTier1; + private long _progression; + private long _tier1; + + /// Initializes a new instance of the class. + /// The resource limits. + /// The length of the input in bytes. + public DecodeBudget(Jpeg2000DecoderLimits limits, long inputLength) + { + // Visiting a packet costs one unit; a valid packet carries at least one header bit, so the + // work a genuine stream needs grows with its length, while a tiny hostile one gets only the base. + _maxProgression = Math.Min(limits.MaxProgressionSteps, ProgressionStepsBase + (ProgressionStepsPerInputByte * inputLength)); + // The entropy-decoding work a genuine stream needs also grows with its length, so a tiny hostile + // stream that declares huge blocks and many passes gets only the base allowance. + _maxTier1 = Math.Min(limits.MaxTier1Work, Tier1WorkBase + (Tier1WorkPerInputByte * inputLength)); + } + + /// Charges progression-iteration work: candidate packets and position steps. + /// The number of steps. + public void ChargeProgression(long amount) + { + _progression += amount; + if (_progression > _maxProgression) + { + throw Malformed("progression order iteration exceeds the decoder limit."); + } + } + + /// Charges entropy-decoding work in sample-passes. + /// The work to charge. + public void ChargeTier1(long amount) + { + _tier1 += amount; + if (_tier1 > _maxTier1) + { + throw Malformed("image requires too much entropy-decoding work."); + } + } + } + + /// Decodes a complete image from a raw codestream or JP2 file held in memory. + /// The input bytes. + /// The resource limits. + /// The decoded image. + private static Jpeg2000Image DecodeImage(byte[] data, Jpeg2000DecoderLimits limits) + { + var jp2 = ParseContainer(data); + var cs = Codestream.Parse(data, jp2.CodestreamStart, jp2.CodestreamEnd, limits); + var siz = cs.Siz; + CheckIhdrAgainstSiz(jp2, siz); + var layout = ResolveLayout(jp2, siz.Csiz); + CheckOutputLimit(siz, layout, limits); + + var planes = AllocatePlanes(siz); + var budget = new DecodeBudget(limits, data.Length); + var tier1 = new Tier1Decoder(); + for (var i = 0; i < cs.Tiles.Length; i++) + { + var td = cs.Tiles[i]; + DecodeTile(siz, td, i, planes, tier1, budget, limits); + td.Release(); + } + + return Assemble(siz, jp2, layout, planes); + } + + /// Checks the size of the 8-bit output (which upsamples subsampled components) before it is allocated. + /// The image size information. + /// The resolved channel layout. + /// The resource limits. + private static void CheckOutputLimit(SizInfo siz, ChannelLayout layout, Jpeg2000DecoderLimits limits) + { + var channels = layout.Color.Length + (layout.Alpha is null ? 0 : 1); + + // Width and height were checked against limits that are within int range, so this cannot overflow. + if (siz.Width * siz.Height * channels > limits.MaxTotalSamples) + { + throw Malformed("image exceeds the decoder sample limit."); + } + } + + /// Allocates the component planes. The sizes were validated against the limits when the SIZ marker was parsed. + /// The image size information. + /// One plane per component, filled with the mid-range value. + private static ushort[][] AllocatePlanes(SizInfo siz) + { + var planes = new ushort[siz.Csiz][]; + for (var c = 0; c < siz.Csiz; c++) + { + var size = (int)((siz.CompX1(c) - siz.CompX0(c)) * (siz.CompY1(c) - siz.CompY0(c))); + var plane = new ushort[size]; + Array.Fill(plane, (ushort)(1 << (siz.Depth[c] - 1))); + planes[c] = plane; + } + + return planes; + } + private static void DecodeTile( + SizInfo siz, TileData td, int index, ushort[][] planes, Tier1Decoder tier1, DecodeBudget budget, Jpeg2000DecoderLimits limits) + { + var st = td.State!; + var body = ByteCursor.FromStream(td.Body); + var packed = td.UsesPackedHeaders ? ByteCursor.FromStream(td.PackedHeaders) : null; + var packetBits = 8L * (body.End + (packed?.End ?? 0)); + var tile = BuildTile(siz, st, index, limits, packetBits); + ReadTilePackets(tile, st, siz, body, packed ?? body, budget); + + // The packets are consumed; free the tile's buffers before the (large) sample reconstruction. + td.Release(); + + var count = tile.Comps.Length; + var ints = new int[count][]; + var floats = new float[count][]; + for (var c = 0; c < count; c++) + { + var tc = tile.Comps[c]; + var size = tc.Res[^1].Width * tc.Res[^1].Height; + if (tc.Coding.Reversible) + { + ints[c] = new int[size]; + } + else + { + floats[c] = new float[size]; + } + + DecodeBlocks(tc, ints[c], floats[c], tier1, budget); + var stride = tc.Res[^1].Width; + if (tc.Coding.Reversible) + { + InverseDwt53(ints[c], stride, tc.Res); + } + else + { + InverseDwt97(floats[c], stride, tc.Res); + } + } + + if (st.Mct) + { + InverseMct(tile, ints, floats); + } + + for (var c = 0; c < count; c++) + { + StoreComponent(siz, tile.Comps[c], c, ints[c], floats[c], planes[c]); + } + } + + private static void DecodeBlocks(TileComp tc, int[]? ints, float[]? floats, Tier1Decoder tier1, DecodeBudget budget) + { + var stride = tc.Res[^1].Width; + foreach (var res in tc.Res) + { + foreach (var precinct in res.Precincts) + { + foreach (var pb in precinct.Bands) + { + foreach (var block in pb.Blocks) + { + tier1.DecodeBlock(block, pb.Band, tc.Coding.Style, tc.RoiShift, tc.Coding.Reversible, ints, floats, stride, budget); + block.Data = []; + } + } + } + } + } + + private static void InverseMct(Tile tile, int[][] ints, float[][] floats) + { + if (tile.Comps.Length < 3) + { + throw Malformed("multiple component transform needs at least three components."); + } + + var a = tile.Comps[0]; + var b = tile.Comps[1]; + var c = tile.Comps[2]; + if (a.Res[^1].Width != b.Res[^1].Width || a.Res[^1].Width != c.Res[^1].Width + || a.Res[^1].Height != b.Res[^1].Height || a.Res[^1].Height != c.Res[^1].Height + || a.Coding.Reversible != b.Coding.Reversible || a.Coding.Reversible != c.Coding.Reversible) + { + throw Malformed("multiple component transform applied to incompatible components."); + } + + if (a.Coding.Reversible) + { + var y0 = ints[0]; + var y1 = ints[1]; + var y2 = ints[2]; + for (var i = 0; i < y0.Length; i++) + { + var g = y0[i] - ((y2[i] + y1[i]) >> 2); + var r = y2[i] + g; + var bl = y1[i] + g; + y0[i] = r; + y1[i] = g; + y2[i] = bl; + } + } + else + { + var y0 = floats[0]; + var y1 = floats[1]; + var y2 = floats[2]; + for (var i = 0; i < y0.Length; i++) + { + var y = y0[i]; + var cb = y1[i]; + var cr = y2[i]; + y0[i] = y + (1.402f * cr); + y1[i] = y - (0.34413f * cb) - (0.71414f * cr); + y2[i] = y + (1.772f * cb); + } + } + } + + private static void StoreComponent(SizInfo siz, TileComp tc, int c, int[]? ints, float[]? floats, ushort[] plane) + { + var w = tc.Res[^1].Width; + var h = tc.Res[^1].Height; + var depth = siz.Depth[c]; + var shift = 1 << (depth - 1); + var max = (1 << depth) - 1; + var ox = (int)(tc.X0 - siz.CompX0(c)); + var oy = (int)(tc.Y0 - siz.CompY0(c)); + var planeWidth = (int)(siz.CompX1(c) - siz.CompX0(c)); + for (var y = 0; y < h; y++) + { + var src = y * w; + var dst = ((oy + y) * planeWidth) + ox; + for (var x = 0; x < w; x++) + { + var v = ints is not null ? ints[src + x] : (int)MathF.Floor(floats![src + x] + 0.5f); + plane[dst + x] = (ushort)Math.Clamp(v + shift, 0, max); + } + } + } + + // ------------------------------------------------------------------------------------------ + // Final assembly: upsampling, palette, 8-bit scaling, color conversion + // ------------------------------------------------------------------------------------------ + + private static Jpeg2000Image Assemble(SizInfo siz, Jp2Info jp2, ChannelLayout layout, ushort[][] planes) + { + var width = (int)siz.Width; + var height = (int)siz.Height; + var nColor = layout.Color.Length; + var color = new byte[checked(width * height * nColor)]; + var alpha = layout.Alpha is null ? null : new byte[checked(width * height)]; + + var sources = new List(layout.Color); + if (layout.Alpha is { } a) + { + sources.Add(a); + } + + var luts = sources.Select(s => BuildLut(siz, jp2.Palette, s)).ToArray(); + var xmaps = sources.Select(s => BuildAxisMap(siz.XOsiz, width, siz.XR[s.Component], siz.CompX0(s.Component), siz.CompX1(s.Component))).ToArray(); + var compWidths = sources.Select(s => (int)(siz.CompX1(s.Component) - siz.CompX0(s.Component))).ToArray(); + var ymaps = sources.Select(s => BuildAxisMap(siz.YOsiz, height, siz.YR[s.Component], siz.CompY0(s.Component), siz.CompY1(s.Component))).ToArray(); + + for (var y = 0; y < height; y++) + { + for (var k = 0; k < sources.Count; k++) + { + var plane = planes[sources[k].Component]; + var row = ymaps[k][y] * compWidths[k]; + if (k < nColor) + { + CopyRow(plane, row, xmaps[k], luts[k], color, (y * width * nColor) + k, nColor); + } + else + { + CopyRow(plane, row, xmaps[k], luts[k], alpha!, y * width, 1); + } + } + } + + if (layout.IsSycc) + { + ConvertSyccToRgb(color); + } + + return new Jpeg2000Image(width, height, layout.ColorSpace, nColor, color, alpha, layout.Premultiplied, jp2.IccProfile?.ToArray()) + { + BitDepth = siz.Depth[layout.Color[0].Component], + HasPalette = layout.Color.Any(s => s.PaletteColumn >= 0) && jp2.Palette is not null, + }; + } + + /// Copies one row of one channel through its look-up table into the interleaved output. + private static void CopyRow(ushort[] plane, int row, int[] xmap, byte[] lut, byte[] output, int start, int stride) + { + var o = start; + for (var x = 0; x < xmap.Length; x++) + { + output[o] = lut[plane[row + xmap[x]]]; + o += stride; + } + } + + private static int[] BuildAxisMap(long origin, int length, int subsampling, long compStart, long compEnd) + { + var map = new int[length]; + var last = (int)(compEnd - compStart - 1); + if (last < 0) + { + throw Malformed("component has no samples."); + } + for (var i = 0; i < length; i++) + { + var v = (int)(((origin + i) / subsampling) - compStart); + map[i] = Math.Clamp(v, 0, last); + } + + return map; + } + + private static byte[] BuildLut(SizInfo siz, PaletteInfo? palette, ChannelSource source) + { + var depth = siz.Depth[source.Component]; + var lut = new byte[1 << depth]; + for (var v = 0; v < lut.Length; v++) + { + if (source.PaletteColumn >= 0 && palette is not null) + { + var entry = Math.Min(v, palette.Entries - 1); + var bits = palette.Depth[source.PaletteColumn]; + lut[v] = ScaleTo8(palette.Values[(entry * palette.Columns) + source.PaletteColumn], bits); + } + else + { + lut[v] = ScaleTo8(v, depth); + } + } + + return lut; + } + + private static byte ScaleTo8(int value, int bits) + { + if (bits == 8) + { + return (byte)value; + } + + long max = (1L << bits) - 1; + return (byte)Math.Min(255L, ((Math.Min(value, max) * 255) + (max / 2)) / max); + } + + private static void ConvertSyccToRgb(byte[] samples) + { + for (var i = 0; i + 2 < samples.Length; i += 3) + { + var y = (float)samples[i]; + var cb = samples[i + 1] - 128f; + var cr = samples[i + 2] - 128f; + samples[i] = ClampByte(y + (1.402f * cr)); + samples[i + 1] = ClampByte(y - (0.344136f * cb) - (0.714136f * cr)); + samples[i + 2] = ClampByte(y + (1.772f * cb)); + } + } + + private static byte ClampByte(float v) => (byte)Math.Clamp((int)MathF.Round(v), 0, 255); +} diff --git a/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Dwt.cs b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Dwt.cs new file mode 100644 index 00000000..e3634d93 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Dwt.cs @@ -0,0 +1,182 @@ +namespace DemaConsulting.CanvasNet.Codecs; + +public static partial class Jpeg2000Codec +{ + // ================================================================================================ + // Inverse discrete wavelet transforms (ITU-T T.800 Annex F): reversible 5/3 and irreversible 9/7 + // ================================================================================================ + + private const float DwtAlpha = -1.586134342059924f; + private const float DwtBeta = -0.052980118572961f; + private const float DwtGamma = 0.882911075530934f; + private const float DwtDelta = 0.443506852043971f; + private const float DwtK = 1.230174104914001f; + + /// Runs the inverse 5/3 transform in place over a Mallat-layout plane. + /// The coefficient plane (row stride equals the full tile-component width). + /// The row stride. + /// The resolution levels, lowest first. + private static void InverseDwt53(int[] plane, int stride, Resolution[] res) + { + var maxDim = 1; + foreach (var r in res) + { + maxDim = Math.Max(maxDim, Math.Max(r.Width, r.Height)); + } + + var line = new int[maxDim]; + for (var r = 1; r < res.Length; r++) + { + var cur = res[r]; + int w = cur.Width, h = cur.Height; + var px = (int)(cur.X0 & 1); + var py = (int)(cur.Y0 & 1); + for (var y = 0; y < h; y++) + { + var rowBase = y * stride; + Interleave(plane, rowBase, 1, w, res[r - 1].Width, px, line); + Lift53(line, w, px); + Array.Copy(line, 0, plane, rowBase, w); + } + + for (var x = 0; x < w; x++) + { + Interleave(plane, x, stride, h, res[r - 1].Height, py, line); + Lift53(line, h, py); + for (var y = 0; y < h; y++) + { + plane[(y * stride) + x] = line[y]; + } + } + } + } + + /// Runs the inverse 9/7 transform in place over a Mallat-layout plane. + /// The coefficient plane. + /// The row stride. + /// The resolution levels, lowest first. + private static void InverseDwt97(float[] plane, int stride, Resolution[] res) + { + var maxDim = 1; + foreach (var r in res) + { + maxDim = Math.Max(maxDim, Math.Max(r.Width, r.Height)); + } + + var line = new float[maxDim]; + for (var r = 1; r < res.Length; r++) + { + var cur = res[r]; + int w = cur.Width, h = cur.Height; + var px = (int)(cur.X0 & 1); + var py = (int)(cur.Y0 & 1); + for (var y = 0; y < h; y++) + { + var rowBase = y * stride; + Interleave(plane, rowBase, 1, w, res[r - 1].Width, px, line); + Lift97(line, w, px); + Array.Copy(line, 0, plane, rowBase, w); + } + + for (var x = 0; x < w; x++) + { + Interleave(plane, x, stride, h, res[r - 1].Height, py, line); + Lift97(line, h, py); + for (var y = 0; y < h; y++) + { + plane[(y * stride) + x] = line[y]; + } + } + } + } + + /// Interleaves a low-pass half followed by a high-pass half into alternating positions. + /// The sample type. + /// The source plane. + /// The index of the first sample of the line. + /// The distance between consecutive samples of the line. + /// The line length. + /// The number of low-pass samples. + /// The parity of the first sample's absolute coordinate. + /// The interleaved output. + private static void Interleave(T[] src, int start, int step, int n, int lowCount, int parity, T[] dest) + { + var lo = 0; + var hi = lowCount; + for (var i = 0; i < n; i++) + { + if (((i + parity) & 1) == 0) + { + dest[i] = src[start + (lo * step)]; + lo++; + } + else + { + dest[i] = src[start + (hi * step)]; + hi++; + } + } + } + + /// Inverse 5/3 lifting of one interleaved line. + /// The line. + /// The line length. + /// The parity of the first sample's absolute coordinate. + private static void Lift53(int[] x, int n, int parity) + { + if (n == 1) + { + if (parity == 1) + { + x[0] >>= 1; + } + + return; + } + + for (var i = parity; i < n; i += 2) + { + x[i] -= (x[i > 0 ? i - 1 : 1] + x[i < n - 1 ? i + 1 : n - 2] + 2) >> 2; + } + + for (var i = 1 - parity; i < n; i += 2) + { + x[i] += (x[i > 0 ? i - 1 : 1] + x[i < n - 1 ? i + 1 : n - 2]) >> 1; + } + } + + /// Inverse 9/7 lifting of one interleaved line. + /// The line. + /// The line length. + /// The parity of the first sample's absolute coordinate. + private static void Lift97(float[] x, int n, int parity) + { + if (n == 1) + { + if (parity == 1) + { + x[0] *= 0.5f; + } + + return; + } + + for (var i = 0; i < n; i++) + { + x[i] *= ((i + parity) & 1) == 0 ? DwtK : 1f / DwtK; + } + + LiftStep(x, n, parity, DwtDelta); + LiftStep(x, n, 1 - parity, DwtGamma); + LiftStep(x, n, parity, DwtBeta); + LiftStep(x, n, 1 - parity, DwtAlpha); + } + + private static void LiftStep(float[] x, int n, int first, float coef) + { + for (var i = first; i < n; i += 2) + { + x[i] -= coef * (x[i > 0 ? i - 1 : 1] + x[i < n - 1 ? i + 1 : n - 2]); + } + } +} diff --git a/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Geometry.cs b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Geometry.cs new file mode 100644 index 00000000..94481913 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Geometry.cs @@ -0,0 +1,541 @@ +namespace DemaConsulting.CanvasNet.Codecs; + +public static partial class Jpeg2000Codec +{ + // ================================================================================================ + // Tile / component / resolution / sub-band / precinct / code-block geometry (ITU-T T.800 Annex B) + // ================================================================================================ + + /// One codeword segment of a code-block: a run of coding passes sharing one termination. + internal sealed class Segment + { + /// Gets or sets the number of coding passes contributed so far. + public int Passes { get; set; } + + /// Gets the maximum number of passes the segment can hold. + public int MaxPasses { get; init; } + + /// Gets or sets the number of coded bytes contributed so far. + public int Length { get; set; } + } + + /// + /// A code-block: a rectangle of sub-band coefficients coded independently. Coordinates are + /// relative to the origin of the owning sub-band so they always fit in an . + /// + internal sealed class CodeBlock + { + /// Gets the left edge in sub-band coordinates. + public int X0 { get; init; } + + /// Gets the top edge in sub-band coordinates. + public int Y0 { get; init; } + + /// Gets the exclusive right edge in sub-band coordinates. + public int X1 { get; init; } + + /// Gets the exclusive bottom edge in sub-band coordinates. + public int Y1 { get; init; } + + /// Gets or sets a value indicating whether the block was included in an earlier packet. + public bool Included { get; set; } + + /// Gets or sets the number of missing most significant bit-planes. + public int ZeroBitPlanes { get; set; } + + /// Gets or sets the Lblock length-indicator state. + public int Lblock { get; set; } = 3; + + /// Gets or sets the total number of coding passes signalled so far. + public int TotalPasses { get; set; } + + /// Gets or sets the accumulated coded bytes of all segments. + public byte[] Data { get; set; } = []; + + /// Gets or sets the number of valid bytes in . + public int DataLength { get; set; } + + /// Gets the codeword segments in order. + public List Segments { get; } = []; + } + + /// One sub-band of a resolution level. + internal sealed class Band + { + /// Gets the orientation: 0 LL, 1 HL, 2 LH, 3 HH. + public int Orient { get; init; } + + /// Gets the left edge in sub-band coordinates. + public long X0 { get; init; } + + /// Gets the top edge in sub-band coordinates. + public long Y0 { get; init; } + + /// Gets the exclusive right edge in sub-band coordinates. + public long X1 { get; init; } + + /// Gets the exclusive bottom edge in sub-band coordinates. + public long Y1 { get; init; } + + /// Gets the number of magnitude bit-planes Mb (guard bits plus exponent minus one). + public int Mb { get; init; } + + /// Gets the quantization step size (1 for reversible coding). + public float Step { get; init; } + + /// Gets the horizontal offset of the band in the in-place (Mallat) tile-component layout. + public int OffX { get; init; } + + /// Gets the vertical offset of the band in the in-place (Mallat) tile-component layout. + public int OffY { get; init; } + } + + /// The part of a precinct that lies in one sub-band. + internal sealed class PrecinctBand + { + /// Gets the sub-band the precinct part belongs to. + public required Band Band { get; init; } + + /// Gets the number of code-block columns. + public int Cw { get; init; } + + /// Gets the number of code-block rows. + public int Ch { get; init; } + + /// Gets the code-blocks in raster order. + public required CodeBlock[] Blocks { get; init; } + + /// Gets the inclusion tag tree (null when there are no code-blocks). + public TagTree? Inclusion { get; init; } + + /// Gets the zero-bit-plane tag tree (null when there are no code-blocks). + public TagTree? ZeroBits { get; init; } + + /// Gets the total number of bit-planes (Mb plus ROI shift) a block can use. + public int TopPlanes { get; init; } + } + + /// A precinct: the unit of packet formation. + internal sealed class Precinct + { + /// Gets the per-sub-band parts of the precinct. + public required PrecinctBand[] Bands { get; init; } + } + + /// One resolution level of a tile-component. + internal sealed class Resolution + { + /// Gets the left edge in resolution coordinates. + public long X0 { get; init; } + + /// Gets the top edge in resolution coordinates. + public long Y0 { get; init; } + + /// Gets the exclusive right edge in resolution coordinates. + public long X1 { get; init; } + + /// Gets the exclusive bottom edge in resolution coordinates. + public long Y1 { get; init; } + + /// Gets the precinct width exponent. + public int PPx { get; init; } + + /// Gets the precinct height exponent. + public int PPy { get; init; } + + /// Gets the number of precinct columns. + public int Pw { get; init; } + + /// Gets the number of precinct rows. + public int Ph { get; init; } + + /// Gets the sub-bands (one for resolution 0, otherwise HL, LH, HH). + public required Band[] Bands { get; init; } + + /// Gets the precincts in raster order. + public required Precinct[] Precincts { get; init; } + + /// Gets the width in samples. + public int Width => (int)(X1 - X0); + + /// Gets the height in samples. + public int Height => (int)(Y1 - Y0); + } + + /// One component of one tile. + internal sealed class TileComp + { + /// Gets the left edge in component samples. + public long X0 { get; init; } + + /// Gets the top edge in component samples. + public long Y0 { get; init; } + + /// Gets the exclusive right edge in component samples. + public long X1 { get; init; } + + /// Gets the exclusive bottom edge in component samples. + public long Y1 { get; init; } + + /// Gets the coding parameters. + public required CodingParams Coding { get; init; } + + /// Gets the ROI max-shift value. + public int RoiShift { get; init; } + + /// Gets the resolution levels, lowest first. + public required Resolution[] Res { get; init; } + + /// Gets the width in samples. + public int Width => (int)(X1 - X0); + + /// Gets the height in samples. + public int Height => (int)(Y1 - Y0); + } + + /// The fully built geometry of one tile. + internal sealed class Tile + { + /// Gets the left edge on the reference grid. + public long X0 { get; init; } + + /// Gets the top edge on the reference grid. + public long Y0 { get; init; } + + /// Gets the exclusive right edge on the reference grid. + public long X1 { get; init; } + + /// Gets the exclusive bottom edge on the reference grid. + public long Y1 { get; init; } + + /// Gets the tile components. + public required TileComp[] Comps { get; init; } + + /// Gets the total number of packets (precincts times layers). + public long TotalPackets { get; init; } + } + + /// Builds the tile geometry for tile with strict resource caps. + /// The image size information. + /// The coding state in effect for the tile. + /// The tile index in raster order. + /// The resource limits. + /// + /// The number of bits the tile-part data of the tile can hold. Every packet has a header of at + /// least one bit, so a tile cannot have more packets than this. + /// + /// The built tile. + private static Tile BuildTile(SizInfo siz, CodingState st, int index, Jpeg2000DecoderLimits limits, long packetBits) + { + var nx = siz.NumXTiles; + var p = index % nx; + var q = index / nx; + var tx0 = Math.Max(siz.XTOsiz + (p * siz.XTsiz), siz.XOsiz); + var tx1 = Math.Min(siz.XTOsiz + ((p + 1L) * siz.XTsiz), siz.Xsiz); + var ty0 = Math.Max(siz.YTOsiz + (q * siz.YTsiz), siz.YOsiz); + var ty1 = Math.Min(siz.YTOsiz + ((q + 1L) * siz.YTsiz), siz.Ysiz); + + // Every limit is checked before the structure it bounds is allocated. The packet bound is the tighter of the + // packet limit and what the tile data could possibly carry. + var budget = new GeometryBudget + { + MaxSamples = limits.MaxTileSamples, + MaxBlocks = limits.MaxTileCodeBlocks, + MaxPrecincts = limits.MaxTilePrecincts, + MaxPackets = limits.MaxTilePackets, + PacketBits = packetBits, + Layers = st.Layers, + }; + var comps = new TileComp[siz.Csiz]; + for (var c = 0; c < siz.Csiz; c++) + { + comps[c] = BuildTileComp(siz, st, c, new TileRegion(tx0, ty0, tx1, ty1), budget); + } + + return new Tile { X0 = tx0, Y0 = ty0, X1 = tx1, Y1 = ty1, Comps = comps, TotalPackets = budget.Precincts * st.Layers }; + } + + /// Running resource counters used while building one tile. + private sealed class GeometryBudget + { + /// Gets the maximum number of samples of the tile. + public long MaxSamples { get; init; } + + /// Gets the maximum number of precincts of the tile. + public long MaxPrecincts { get; init; } + + /// Gets the maximum number of packets (precincts times layers) of the tile. + public long MaxPackets { get; init; } + + /// Gets the number of bits the tile data can hold; a packet needs at least one header bit. + public long PacketBits { get; init; } + + /// Gets the number of layers. + public int Layers { get; init; } + + /// Gets the maximum number of code-blocks of the tile. + public long MaxBlocks { get; init; } + + /// Gets or sets the number of samples allocated so far. + public long Samples { get; set; } + + /// Gets or sets the number of precincts created so far. + public long Precincts { get; set; } + + /// Gets or sets the number of code-blocks created so far. + public long Blocks { get; set; } + + /// Adds precincts to the running count and enforces the precinct and packet bounds before they are allocated. + /// The number of precincts about to be created. + public void ChargePrecincts(long count) + { + Precincts += count; + if (Precincts > MaxPrecincts) + { + throw Malformed("tile has more precincts than the decoder limit."); + } + + // Precincts are within the int range here and Layers is at most 65535, so the product cannot overflow. + var packets = Precincts * Layers; + if (packets > MaxPackets) + { + throw Malformed("tile has more packets than the decoder limit."); + } + + if (packets > PacketBits) + { + throw Malformed("tile has more packets than its data can carry."); + } + } + } + + /// A rectangle in reference-grid or tile-component coordinates (exclusive upper bounds). + private readonly record struct TileRegion(long X0, long Y0, long X1, long Y1); + + private static TileComp BuildTileComp(SizInfo siz, CodingState st, int c, TileRegion tile, GeometryBudget budget) + { + var coding = st.Coding[c]!; + var quant = st.Quant[c]!; + var levels = coding.Levels; + + // T.800 A.6.4: no-quantization (style 0) carries exactly one entry per sub-band, 3 * levels + 1. The decomposition + // levels of the effective COD/COC are only known here, after any marker ordering (QCD before COD) has settled. + if (quant.Style == 0 && quant.Exp.Length != (3 * levels) + 1) + { + throw Malformed($"quantization segment has {quant.Exp.Length} entries but {(3 * levels) + 1} sub-bands."); + } + + var tcx0 = CeilDiv(tile.X0, siz.XR[c]); + var tcy0 = CeilDiv(tile.Y0, siz.YR[c]); + var tcx1 = CeilDiv(tile.X1, siz.XR[c]); + var tcy1 = CeilDiv(tile.Y1, siz.YR[c]); + + // Memory cap on the decoded sample planes of the tile. + budget.Samples += (tcx1 - tcx0) * (tcy1 - tcy0); + if (budget.Samples > budget.MaxSamples) + { + throw Malformed("tile exceeds the decoder sample limit."); + } + + var res = new Resolution[levels + 1]; + for (var r = 0; r <= levels; r++) + { + res[r] = BuildResolution(siz, st, c, r, new TileRegion(tcx0, tcy0, tcx1, tcy1), r > 0 ? res[r - 1] : null, budget); + } + + return new TileComp { X0 = tcx0, Y0 = tcy0, X1 = tcx1, Y1 = tcy1, Coding = coding, RoiShift = st.RoiShift[c], Res = res }; + } + + private static Resolution BuildResolution(SizInfo siz, CodingState st, int c, int r, TileRegion tc, Resolution? lower, GeometryBudget budget) + { + var coding = st.Coding[c]!; + var levels = coding.Levels; + var lvl = levels - r; + var rx0 = CeilDivPow2(tc.X0, lvl); + var ry0 = CeilDivPow2(tc.Y0, lvl); + var rx1 = CeilDivPow2(tc.X1, lvl); + var ry1 = CeilDivPow2(tc.Y1, lvl); + + // Sub-bands (Annex B.5): LL for resolution 0, else HL, LH and HH at decomposition level nb. + var bands = new Band[r == 0 ? 1 : 3]; + for (var b = 0; b < bands.Length; b++) + { + var orient = r == 0 ? 0 : b + 1; + bands[b] = BuildBand(siz, st, c, r, orient, tc, lower); + } + + // Precinct grid (anchored at the origin of the resolution coordinate system). + var ppx = coding.PPx[r]; + var ppy = coding.PPy[r]; + var pwLong = rx1 > rx0 ? CeilDivPow2(rx1, ppx) - (rx0 >> ppx) : 0; + var phLong = ry1 > ry0 ? CeilDivPow2(ry1, ppy) - (ry0 >> ppy) : 0; + var count = pwLong * phLong; + budget.ChargePrecincts(count); + + var pw = (int)pwLong; + var ph = (int)phLong; + + var precincts = new Precinct[count]; + for (var j = 0; j < ph; j++) + { + for (var i = 0; i < pw; i++) + { + var pxAbs = (rx0 >> ppx) + i; + var pyAbs = (ry0 >> ppy) + j; + precincts[(j * pw) + i] = BuildPrecinct(coding, st.RoiShift[c], r, bands, pxAbs, pyAbs, budget); + } + } + + return new Resolution + { + X0 = rx0, + Y0 = ry0, + X1 = rx1, + Y1 = ry1, + PPx = ppx, + PPy = ppy, + Pw = pw, + Ph = ph, + Bands = bands, + Precincts = precincts, + }; + } + + private static Band BuildBand(SizInfo siz, CodingState st, int c, int r, int orient, TileRegion tc, Resolution? lower) + { + var coding = st.Coding[c]!; + var quant = st.Quant[c]!; + var levels = coding.Levels; + var (x0, y0, x1, y1) = BandBounds(levels, r, orient, tc); + var offX = 0; + var offY = 0; + if (r > 0) + { + offX = orient is 1 or 3 ? lower!.Width : 0; + offY = orient is 2 or 3 ? lower!.Height : 0; + } + + var (exp, mant) = BandQuantization(quant, r, orient); + + // The bit-plane count is validated once, here, so tier-2 and tier-1 can rely on it: the magnitude + // bit-planes Mb must be positive and plausible, and Mb plus the ROI shift must fit the decoder. + var mb = quant.GuardBits + exp - 1; + if (exp < 0 || mb < 0 || mb > MaxBitPlanes) + { + throw Malformed("invalid quantization exponent."); + } + + if (mb + st.RoiShift[c] > MaxBitPlanes) + { + throw Unsupported("jpeg2000-bitplanes", $"more than {MaxBitPlanes} coded bit-planes."); + } + + var gain = orient switch { 0 => 0, 3 => 2, _ => 1 }; + var step = coding.Reversible ? 1f : (float)(Math.Pow(2, siz.Depth[c] + gain - exp) * (1.0 + (mant / 2048.0))); + return new Band { Orient = orient, X0 = x0, Y0 = y0, X1 = x1, Y1 = y1, Mb = mb, Step = step, OffX = offX, OffY = offY }; + } + + /// Computes the sub-band bounds from the tile-component bounds (Annex B.5). + private static (long X0, long Y0, long X1, long Y1) BandBounds(int levels, int r, int orient, TileRegion tc) + { + if (r == 0) + { + return (CeilDivPow2(tc.X0, levels), CeilDivPow2(tc.Y0, levels), CeilDivPow2(tc.X1, levels), CeilDivPow2(tc.Y1, levels)); + } + + var nb = levels - r + 1; + var xob = orient is 1 or 3 ? 1L : 0L; + var yob = orient is 2 or 3 ? 1L : 0L; + var shift = nb - 1; + return ( + CeilDivPow2(tc.X0 - (xob << shift), nb), + CeilDivPow2(tc.Y0 - (yob << shift), nb), + CeilDivPow2(tc.X1 - (xob << shift), nb), + CeilDivPow2(tc.Y1 - (yob << shift), nb)); + } + + /// Selects the quantization exponent and mantissa of a band (Annex E): derived style extrapolates from the LL entry. + private static (int Exp, int Mant) BandQuantization(QuantParams quant, int r, int orient) + { + if (quant.Style == 1) + { + return (quant.Exp[0] - (r == 0 ? 0 : r - 1), quant.Mant[0]); + } + + var bandIndex = r == 0 ? 0 : (3 * (r - 1)) + orient; + if (bandIndex >= quant.Exp.Length) + { + throw Malformed("quantization segment has too few entries."); + } + + return (quant.Exp[bandIndex], quant.Mant[bandIndex]); + } + private static Precinct BuildPrecinct( + CodingParams coding, int roiShift, int r, Band[] bands, long pxAbs, long pyAbs, GeometryBudget budget) + { + var ppx = coding.PPx[r]; + var ppy = coding.PPy[r]; + var parts = new PrecinctBand[bands.Length]; + for (var b = 0; b < bands.Length; b++) + { + var band = bands[b]; + + // Precinct region in sub-band coordinates: halved for resolutions above 0 (Annex B.6). + var sx = r == 0 ? ppx : ppx - 1; + var sy = r == 0 ? ppy : ppy - 1; + var x0 = Math.Max(pxAbs << sx, band.X0); + var x1 = Math.Min((pxAbs + 1) << sx, band.X1); + var y0 = Math.Max(pyAbs << sy, band.Y0); + var y1 = Math.Min((pyAbs + 1) << sy, band.Y1); + var top = band.Mb + roiShift; + if (x1 <= x0 || y1 <= y0) + { + parts[b] = new PrecinctBand { Band = band, Blocks = [], TopPlanes = top }; + continue; + } + + // Code-block grid is anchored at the origin; its size is clamped to the precinct size. + var xcb = Math.Min(coding.Xcb, sx); + var ycb = Math.Min(coding.Ycb, sy); + var cbx0 = x0 >> xcb; + var cby0 = y0 >> ycb; + var cwLong = CeilDivPow2(x1, xcb) - cbx0; + var chLong = CeilDivPow2(y1, ycb) - cby0; + budget.Blocks += cwLong * chLong; + if (budget.Blocks > budget.MaxBlocks) + { + throw Malformed("tile has too many code-blocks."); + } + + var cw = (int)cwLong; + var ch = (int)chLong; + var blocks = new CodeBlock[cw * ch]; + for (var j = 0; j < ch; j++) + { + for (var i = 0; i < cw; i++) + { + blocks[(j * cw) + i] = new CodeBlock + { + X0 = (int)(Math.Max(x0, (cbx0 + i) << xcb) - band.X0), + Y0 = (int)(Math.Max(y0, (cby0 + j) << ycb) - band.Y0), + X1 = (int)(Math.Min(x1, (cbx0 + i + 1) << xcb) - band.X0), + Y1 = (int)(Math.Min(y1, (cby0 + j + 1) << ycb) - band.Y0), + }; + } + } + + parts[b] = new PrecinctBand + { + Band = band, + Cw = cw, + Ch = ch, + Blocks = blocks, + TopPlanes = top, + Inclusion = new TagTree(cw, ch), + ZeroBits = new TagTree(cw, ch), + }; + } + + return new Precinct { Bands = parts }; + } +} diff --git a/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Jp2.cs b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Jp2.cs new file mode 100644 index 00000000..228caa23 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Jp2.cs @@ -0,0 +1,611 @@ +namespace DemaConsulting.CanvasNet.Codecs; + +public static partial class Jpeg2000Codec +{ + // ================================================================================================ + // JP2 file format boxes (ITU-T T.800 Annex I) and channel layout resolution + // ================================================================================================ + + private const long BoxJp2Signature = 0x6A502020; + private const long BoxFileType = 0x66747970; + private const long BoxJp2Header = 0x6A703268; + private const long BoxIhdr = 0x69686472; + private const long BoxColr = 0x636F6C72; + private const long BoxPclr = 0x70636C72; + private const long BoxCmap = 0x636D6170; + private const long BoxCdef = 0x63646566; + private const long BoxCodestream = 0x6A703263; + + /// The maximum number of boxes examined at one nesting level. + private const int MaxBoxes = 4096; + + /// The maximum number of channels (after palette mapping) the decoder handles. + private const int MaxChannels = 16; + + /// Palette data from the pclr box. + internal sealed class PaletteInfo + { + /// Gets the number of palette entries. + public int Entries { get; init; } + + /// Gets the number of palette columns. + public int Columns { get; init; } + + /// Gets the bit depth of each column. + public required int[] Depth { get; init; } + + /// Gets the entry values, row-major (entry * Columns + column). + public required int[] Values { get; init; } + } + + /// One component mapping record from the cmap box. + internal readonly record struct ComponentMapping(int Component, int Type, int Column); + + /// One channel definition record from the cdef box. + internal readonly record struct ChannelDefinition(int Channel, int Type, int Association); + + /// The parsed JP2 wrapper: where the codestream is and how to interpret it. + internal sealed class Jp2Info + { + /// Gets or sets the offset of the codestream. + public int CodestreamStart { get; set; } + + /// Gets or sets the exclusive end of the codestream. + public int CodestreamEnd { get; set; } + + /// Gets or sets the image width declared by the ihdr box, or -1 when there is none (a raw codestream). + public long IhdrWidth { get; set; } = -1; + + /// Gets or sets the image height declared by the ihdr box, or -1 when there is none. + public long IhdrHeight { get; set; } = -1; + + /// Gets or sets a value indicating whether a jp2h box has been parsed. + public bool HasHeaderBox { get; set; } + + /// Gets or sets the enumerated color space, or -1 when none was specified. + public int EnumCs { get; set; } = -1; + + /// + /// Gets or sets the embedded ICC profile, if any, as a slice of the already-buffered input (no copy is + /// made until the public image is built). For GetInfo it holds only the leading bytes that were read. + /// + public ReadOnlyMemory? IccProfile { get; set; } + + /// Gets or sets the palette, if any. + public PaletteInfo? Palette { get; set; } + + /// Gets the component mappings. + public List Mappings { get; } = []; + + /// Gets the channel definitions. + public List Definitions { get; } = []; + } + + /// Where a channel's samples come from: a component, optionally mapped through a palette column. + internal readonly record struct ChannelSource(int Component, int PaletteColumn); + + /// The resolved interpretation of the channels of an image. + internal sealed class ChannelLayout + { + /// Gets the color space. + public Jpeg2000ColorSpace ColorSpace { get; init; } + + /// Gets a value indicating whether the color channels are sYCC and must be converted. + public bool IsSycc { get; init; } + + /// Gets the color channel sources in order. + public required ChannelSource[] Color { get; init; } + + /// Gets the alpha channel source, or . + public ChannelSource? Alpha { get; init; } + + /// Gets a value indicating whether the alpha channel is premultiplied. + public bool Premultiplied { get; init; } + } + + /// Detects whether the data is a raw codestream or a JP2 file and locates the codestream. + /// The input bytes. + /// The wrapper information. + private static Jp2Info ParseContainer(byte[] data) + { + if (data.Length >= 2 && data[0] == 0xFF && data[1] == 0x4F) + { + return new Jp2Info { CodestreamStart = 0, CodestreamEnd = data.Length }; + } + + if (data.Length < 12 || ReadBe32(data, 0) != 12 || ReadBe32(data, 4) != BoxJp2Signature) + { + throw Malformed("data is neither a JPEG 2000 codestream nor a JP2 file."); + } + + if (ReadBe32(data, 8) != 0x0D0A870A) + { + throw Malformed("invalid JP2 signature box content."); + } + + var info = new Jp2Info(); + var ftyp = ReadBoxHeader(data, 12, data.Length); + CheckFileTypeBox(ftyp); + var pos = ftyp.End; + var found = false; + for (var count = 0; count < MaxBoxes && pos < data.Length; count++) + { + var box = ReadBoxHeader(data, pos, data.Length); + if (box.Type == BoxJp2Header) + { + info.HasHeaderBox = true; + ParseHeaderBoxes(data, box.ContentStart, box.End, info); + } + else if (box.Type == BoxCodestream) + { + CheckHeaderBeforeCodestream(info); + info.CodestreamStart = box.ContentStart; + info.CodestreamEnd = box.End; + found = true; + break; + } + + pos = box.End; + } + + if (!found) + { + throw Malformed("JP2 file has no codestream box."); + } + + return info; + } + + /// + /// Checks the box that must directly follow the JP2 signature box: ftyp (ITU-T T.800 I.5.2), whose + /// content is the brand, minor version and a compatibility list, so at least 8 bytes and a multiple of 4. The + /// brand and compatibility list are deliberately not interpreted (OpenJPEG does not either). + /// + /// The box found after the signature box. + private static void CheckFileTypeBox(BoxHeader box) + { + if (box.Type != BoxFileType) + { + throw Malformed("the file type box must follow the JP2 signature box."); + } + + var length = box.End - box.ContentStart; + if (length < 8 || length % 4 != 0) + { + throw Malformed("invalid file type box length."); + } + } + + /// + /// Checks that the header box, and an ihdr box within it, came before the codestream box (T.800 I.5.3). + /// The position of ihdr inside jp2h and the presence of colr are deliberately not + /// enforced: OpenJPEG decodes such files. + /// + /// The container information gathered so far. + private static void CheckHeaderBeforeCodestream(Jp2Info info) + { + if (!info.HasHeaderBox) + { + throw Malformed("JP2 header box must precede the codestream box."); + } + + if (info.IhdrWidth < 0) + { + throw Malformed("JP2 header box has no image header box."); + } + } + + /// Checks the ihdr image size against the SIZ image size; a raw codestream has no ihdr and passes. + /// The container information. + /// The validated SIZ information. + private static void CheckIhdrAgainstSiz(Jp2Info info, SizInfo siz) + { + if (info.IhdrWidth >= 0 && (info.IhdrWidth != siz.Width || info.IhdrHeight != siz.Height)) + { + throw Malformed("image header box size does not match the codestream SIZ size."); + } + } + + /// Reads the ihdr box: exactly 14 bytes; the first one wins, as in OpenJPEG. + private static void ParseIhdr(byte[] data, BoxHeader box, Jp2Info info) + { + if (box.End - box.ContentStart != 14) + { + throw Malformed("invalid image header box length."); + } + + if (info.IhdrWidth >= 0) + { + return; + } + + // Height precedes width. NC, BPC, C, UnkC and IPR are deliberately not checked (OpenJPEG only warns). + info.IhdrHeight = ReadBe32(data, box.ContentStart); + info.IhdrWidth = ReadBe32(data, box.ContentStart + 4); + } + + private static long ReadBe32(byte[] data, int pos) => + ((long)data[pos] << 24) | ((long)data[pos + 1] << 16) | ((long)data[pos + 2] << 8) | data[pos + 3]; + + private readonly record struct BoxHeader(long Type, int ContentStart, int End); + + private static BoxHeader ReadBoxHeader(byte[] data, int pos, int limit) + { + if (limit - pos < 8) + { + throw Malformed("truncated box header."); + } + + var length = ReadBe32(data, pos); + var type = ReadBe32(data, pos + 4); + var header = 8; + if (length == 1) + { + if (limit - pos < 16) + { + throw Malformed("truncated extended box header."); + } + + var hi = ReadBe32(data, pos + 8); + var lo = ReadBe32(data, pos + 12); + length = (hi << 32) | lo; + header = 16; + if (length < 0) + { + throw Malformed("box length is out of range."); + } + } + else if (length == 0) + { + length = limit - pos; + } + + if (length < header || length > limit - pos) + { + throw Malformed("invalid box length."); + } + + return new BoxHeader(type, pos + header, pos + (int)length); + } + + private static void ParseHeaderBoxes(byte[] data, int start, int end, Jp2Info info) + { + var pos = start; + for (var count = 0; count < MaxBoxes && pos < end; count++) + { + var box = ReadBoxHeader(data, pos, end); + ParseHeaderChild(data, box, info); + pos = box.End; + } + } + + /// Interprets one child box of jp2h (the first of each kind wins); other boxes are ignored. + private static void ParseHeaderChild(byte[] data, BoxHeader box, Jp2Info info) + { + switch (box.Type) + { + case BoxIhdr: + ParseIhdr(data, box, info); + break; + case BoxColr: + ParseColr(data, box, info); + break; + case BoxPclr: + info.Palette ??= ParsePalette(data, box); + break; + case BoxCmap: + if (info.Mappings.Count == 0) + { + ParseCmap(data, box, info); + } + + break; + case BoxCdef: + if (info.Definitions.Count == 0) + { + ParseCdef(data, box, info); + } + + break; + default: + break; + } + } + + private static void ParseColr(byte[] data, BoxHeader box, Jp2Info info) + { + if (box.End - box.ContentStart < 3) + { + throw Malformed("truncated color specification box."); + } + + if (info.EnumCs >= 0 || info.IccProfile is not null) + { + return; + } + + var method = data[box.ContentStart]; + var body = box.ContentStart + 3; + if (method == 1) + { + var length = box.End - body; + var enumCs = length >= 4 ? ReadBe32(data, body) : -1; + + // EnumCS 14 (CIELab) and 19 (CIEJab) carry extra parameter bytes; every other enumerated space is exactly 4. + if (length < 4 || (length != 4 && enumCs != 14 && enumCs != 19)) + { + throw Malformed("color specification box has an invalid length."); + } + + info.EnumCs = (int)Math.Min(enumCs, int.MaxValue); + } + else if (method == 2) + { + info.IccProfile = new ReadOnlyMemory(data, body, box.End - body); + } + } + + private static PaletteInfo ParsePalette(byte[] data, BoxHeader box) + { + var r = new ByteReader(data, box.ContentStart, box.End); + var entries = r.ReadU16(); + var columns = r.ReadU8(); + if (entries < 1 || entries > 1024 || columns < 1) + { + throw Malformed("invalid palette dimensions."); + } + + var depth = new int[columns]; + for (var i = 0; i < columns; i++) + { + var depthByte = r.ReadU8(); + if ((depthByte & 0x80) != 0) + { + throw Unsupported("jpeg2000-signed-palette", "palette columns with signed entries."); + } + + depth[i] = (depthByte & 0x7F) + 1; + if (depth[i] > 16) + { + throw Unsupported("jpeg2000-bit-depth", "palette entries deeper than 16 bits."); + } + } + + var values = new int[entries * columns]; + for (var e = 0; e < entries; e++) + { + for (var c = 0; c < columns; c++) + { + values[(e * columns) + c] = depth[c] > 8 ? r.ReadU16() : r.ReadU8(); + } + } + + r.RequireEnd("palette box"); + return new PaletteInfo { Entries = entries, Columns = columns, Depth = depth, Values = values }; + } + + private static void ParseCmap(byte[] data, BoxHeader box, Jp2Info info) + { + var r = new ByteReader(data, box.ContentStart, box.End); + if (r.Remaining % 4 != 0 || r.Remaining / 4 > MaxChannels) + { + throw Malformed("invalid component mapping box."); + } + + while (r.Remaining > 0) + { + var comp = r.ReadU16(); + var type = r.ReadU8(); + var column = r.ReadU8(); + if (type > 1) + { + throw Malformed("invalid component mapping type."); + } + + info.Mappings.Add(new ComponentMapping(comp, type, column)); + } + } + + private static void ParseCdef(byte[] data, BoxHeader box, Jp2Info info) + { + var r = new ByteReader(data, box.ContentStart, box.End); + var n = r.ReadU16(); + if (n > MaxChannels || r.Remaining != n * 6) + { + throw Malformed("invalid channel definition box."); + } + + for (var i = 0; i < n; i++) + { + info.Definitions.Add(new ChannelDefinition(r.ReadU16(), r.ReadU16(), r.ReadU16())); + } + } + + /// Works out which channels are color and alpha, and in which color space. + /// The wrapper information (an empty one for raw codestreams). + /// The number of codestream components. + /// The resolved layout. + private static ChannelLayout ResolveLayout(Jp2Info jp2, int componentCount) + { + var channels = BuildChannels(jp2, componentCount); + var space = ColorSpaceOf(jp2); + var sycc = jp2.EnumCs == 18; + var expected = space switch + { + Jpeg2000ColorSpace.Gray => 1, + Jpeg2000ColorSpace.Srgb => 3, + Jpeg2000ColorSpace.Cmyk => 4, + _ => 0, + }; + + var color = new List(); + ChannelSource? alpha = null; + var premultiplied = false; + if (jp2.Definitions.Count > 0) + { + (alpha, premultiplied) = ApplyDefinitions(jp2, channels, color); + } + else + { + var take = expected > 0 ? expected : DefaultColorCount(channels.Length); + if (channels.Length < take) + { + throw Malformed("image has fewer channels than its color space requires."); + } + + color.AddRange(channels.Take(take)); + } + + CheckColorChannelCount(color.Count, expected); + + // Heuristic, documented: only when the file carries no color specification at all (a raw codestream, or a + // JP2 file without a usable colr box) are one, three and four color channels taken as gray, RGB and CMYK. + space = InferUnspecifiedColorSpace(jp2, space, color.Count); + + if (color.Count is not (1 or 3 or 4)) + { + throw Unsupported("jpeg2000-channels", $"{color.Count} color channels are not supported."); + } + + // Four color channels are converted as CMYK, so they must be CMYK: either declared, or (above) the + // heuristic for unspecified color. Any other declared color space with four channels is not guessed at. + if (color.Count == 4 && space != Jpeg2000ColorSpace.Cmyk) + { + throw Unsupported("jpeg2000-color-space", "four color channels in a color space other than CMYK."); + } + + return new ChannelLayout + { + ColorSpace = space, + IsSycc = sycc && color.Count == 3, + Color = [.. color], + Alpha = alpha, + Premultiplied = premultiplied, + }; + } + + /// Applies the documented heuristic for a file with no color specification at all; otherwise returns the declared space. + private static Jpeg2000ColorSpace InferUnspecifiedColorSpace(Jp2Info jp2, Jpeg2000ColorSpace space, int colorCount) + { + if (space != Jpeg2000ColorSpace.Unknown || jp2.EnumCs >= 0 || jp2.IccProfile is not null) + { + return space; + } + + return colorCount switch { 1 => Jpeg2000ColorSpace.Gray, 3 => Jpeg2000ColorSpace.Srgb, 4 => Jpeg2000ColorSpace.Cmyk, _ => Jpeg2000ColorSpace.Unknown }; + } + + /// Checks the number of color channels against the count the color space requires (0 when unspecified). + private static void CheckColorChannelCount(int count, int expected) + { + if (expected > 0 && count != expected) + { + throw Malformed("channel count does not match the color space."); + } + + if (count == 0) + { + throw Malformed("image has no color channels."); + } + } + + /// Applies the channel definitions: fills the ordered color channels and returns the alpha channel, if any. + private static (ChannelSource? Alpha, bool Premultiplied) ApplyDefinitions(Jp2Info jp2, ChannelSource[] channels, List color) + { + ChannelSource? alpha = null; + var premultiplied = false; + var byAssoc = new SortedDictionary(); + foreach (var def in jp2.Definitions) + { + if (def.Channel >= channels.Length) + { + throw Malformed("channel definition refers to a missing channel."); + } + + if (def.Type == 0 && def.Association >= 1 && def.Association <= MaxChannels) + { + byAssoc[def.Association] = channels[def.Channel]; + } + else if ((def.Type == 1 || def.Type == 2) && alpha is null) + { + alpha = channels[def.Channel]; + premultiplied = def.Type == 2; + } + } + + var expectedAssoc = 1; + foreach (var (assoc, source) in byAssoc) + { + if (assoc != expectedAssoc++) + { + throw Malformed("channel definitions leave a gap in the color channels."); + } + + color.Add(source); + } + + return (alpha, premultiplied); + } + + private static int DefaultColorCount(int channelCount) => channelCount switch + { + <= 2 => 1, + 3 => 3, + 4 => 4, + _ => throw Unsupported("jpeg2000-channels", "more than four channels without a channel definition."), + }; + + private static ChannelSource[] BuildChannels(Jp2Info jp2, int componentCount) + { + if (jp2.Palette is null || jp2.Mappings.Count == 0) + { + if (jp2.Palette is not null) + { + throw Malformed("palette without a component mapping."); + } + + return [.. Enumerable.Range(0, componentCount).Select(c => new ChannelSource(c, -1))]; + } + + var list = new ChannelSource[jp2.Mappings.Count]; + for (var i = 0; i < list.Length; i++) + { + var m = jp2.Mappings[i]; + if (m.Component >= componentCount || (m.Type == 1 && m.Column >= jp2.Palette.Columns)) + { + throw Malformed("component mapping refers to a missing component or palette column."); + } + + list[i] = new ChannelSource(m.Component, m.Type == 1 ? m.Column : -1); + } + + return list; + } + + private static Jpeg2000ColorSpace ColorSpaceOf(Jp2Info jp2) + { + if (jp2.EnumCs >= 0) + { + return jp2.EnumCs switch + { + 16 or 18 => Jpeg2000ColorSpace.Srgb, + 17 => Jpeg2000ColorSpace.Gray, + 12 => Jpeg2000ColorSpace.Cmyk, + _ => Jpeg2000ColorSpace.Unknown, + }; + } + + if (jp2.IccProfile is { Length: >= 20 } icc) + { + return System.Text.Encoding.ASCII.GetString(icc.Span.Slice(16, 4)) switch + { + "GRAY" => Jpeg2000ColorSpace.Gray, + "RGB " => Jpeg2000ColorSpace.Srgb, + "CMYK" => Jpeg2000ColorSpace.Cmyk, + _ => Jpeg2000ColorSpace.Unknown, + }; + } + + return Jpeg2000ColorSpace.Unknown; + } +} diff --git a/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Mq.cs b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Mq.cs new file mode 100644 index 00000000..536f3bf9 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Mq.cs @@ -0,0 +1,246 @@ +namespace DemaConsulting.CanvasNet.Codecs; + +public static partial class Jpeg2000Codec +{ + // ================================================================================================ + // MQ arithmetic decoder (ITU-T T.800 Annex C) and the raw (bypass) bit reader + // ================================================================================================ + + /// The MQ-coder probability state table (Table C.2): Qe, NMPS, NLPS and SWITCH per state. + private static readonly ushort[] MqQe = + [ + 0x5601, 0x3401, 0x1801, 0x0AC1, 0x0521, 0x0221, 0x5601, 0x5401, 0x4801, 0x3801, 0x3001, 0x2401, 0x1C01, 0x1601, + 0x5601, 0x5401, 0x5101, 0x4801, 0x3801, 0x3401, 0x3001, 0x2801, 0x2401, 0x2201, 0x1C01, 0x1801, 0x1601, 0x1401, + 0x1201, 0x1101, 0x0AC1, 0x09C1, 0x08A1, 0x0521, 0x0441, 0x02A1, 0x0221, 0x0141, 0x0111, 0x0085, 0x0049, 0x0025, + 0x0015, 0x0009, 0x0005, 0x0001, 0x5601, + ]; + + private static readonly byte[] MqNextMps = + [ + 1, 2, 3, 4, 5, 38, 7, 8, 9, 10, 11, 12, 13, 29, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, + 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 45, 46, + ]; + + private static readonly byte[] MqNextLps = + [ + 1, 6, 9, 12, 29, 33, 6, 14, 14, 14, 17, 18, 20, 21, 14, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, + 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 46, + ]; + + private static readonly byte[] MqSwitch = + [ + 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, + ]; + + /// The number of MQ contexts used by the tier-1 coder (0-8 ZC, 9-13 SC, 14-16 MR, 17 RL, 18 UNI). + private const int MqContextCount = 19; + + /// The MQ decoder with its adaptive context states. + internal sealed class MqDecoder + { + private readonly byte[] _state = new byte[MqContextCount]; + private readonly byte[] _mps = new byte[MqContextCount]; + private byte[] _data = []; + private int _pos; + private int _end; + private uint _c; + private uint _a; + private int _ct; + + /// Resets all contexts to their initial states (Table D.7). + public void ResetContexts() + { + Array.Clear(_state); + Array.Clear(_mps); + _state[0] = 4; + _state[17] = 3; + _state[18] = 46; + } + + /// Initializes the arithmetic decoder on a codeword segment (INITDEC). + /// The buffer holding the segment. + /// The first byte of the segment. + /// The segment length in bytes. + public void Init(byte[] data, int start, int length) + { + _data = data; + _pos = start; + _end = start + length; + _c = (uint)ByteAt(_pos) << 16; + ByteIn(); + _c <<= 7; + _ct -= 7; + _a = 0x8000; + } + + /// Decodes one decision in context . + /// The context index. + /// The decoded bit. + public int DecodeBit(int cx) + { + int i = _state[cx]; + uint qe = MqQe[i]; + int d; + _a -= qe; + if ((_c >> 16) < qe) + { + // Conditional exchange on the LPS interval. + if (_a < qe) + { + d = _mps[cx]; + _state[cx] = MqNextMps[i]; + } + else + { + d = 1 - _mps[cx]; + if (MqSwitch[i] != 0) + { + _mps[cx] = (byte)(1 - _mps[cx]); + } + + _state[cx] = MqNextLps[i]; + } + + _a = qe; + Renormalize(); + } + else + { + _c -= qe << 16; + if ((_a & 0x8000) == 0) + { + if (_a < qe) + { + d = 1 - _mps[cx]; + if (MqSwitch[i] != 0) + { + _mps[cx] = (byte)(1 - _mps[cx]); + } + + _state[cx] = MqNextLps[i]; + } + else + { + d = _mps[cx]; + _state[cx] = MqNextMps[i]; + } + + Renormalize(); + } + else + { + d = _mps[cx]; + } + } + + return d; + } + + private int ByteAt(int index) => index < _end ? _data[index] : 0xFF; + + private void ByteIn() + { + // Past the end of the segment the decoder is fed 0xFF bytes, as if a marker followed. + var cur = ByteAt(_pos); + var next = (uint)ByteAt(_pos + 1); + if (cur == 0xFF) + { + if (next > 0x8F) + { + _c += 0xFF00; + _ct = 8; + } + else + { + _pos++; + _c += next << 9; + _ct = 7; + } + } + else + { + _pos++; + _c += next << 8; + _ct = 8; + } + } + + private void Renormalize() + { + do + { + if (_ct == 0) + { + ByteIn(); + } + + _a <<= 1; + _c <<= 1; + _ct--; + } + while ((_a & 0x8000) == 0); + } + } + + /// Reads the raw (selective arithmetic coding bypass) bits of a codeword segment. + internal sealed class RawBitReader + { + private byte[] _data = []; + private int _pos; + private int _end; + private int _c; + private int _ct; + + /// Starts reading a raw segment. + /// The buffer holding the segment. + /// The first byte of the segment. + /// The segment length in bytes. + public void Init(byte[] data, int start, int length) + { + _data = data; + _pos = start; + _end = start + length; + _c = 0; + _ct = 0; + } + + /// Reads one raw bit; reading past the segment yields the 0xFF fill. + /// The bit. + public int ReadBit() + { + if (_ct == 0) + { + // A byte following 0xFF only has seven payload bits. + var next = _pos < _end ? _data[_pos] : 0xFF; + if (_c == 0xFF) + { + if (next > 0x8F) + { + _c = 0xFF; + _ct = 8; + } + else + { + _c = next; + _pos++; + _ct = 7; + } + } + else + { + _c = next; + if (_pos < _end) + { + _pos++; + } + + _ct = 8; + } + } + + _ct--; + return (_c >> _ct) & 1; + } + } +} diff --git a/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Packets.cs b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Packets.cs new file mode 100644 index 00000000..18288720 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Packets.cs @@ -0,0 +1,718 @@ +namespace DemaConsulting.CanvasNet.Codecs; + +public static partial class Jpeg2000Codec +{ + // ================================================================================================ + // Tier-2: tag trees, packet header bit reader, progression orders and packet parsing + // ================================================================================================ + + /// Identifies one packet: a precinct of one resolution of one component in one layer. + /// The layer index. + /// The resolution index. + /// The component index. + /// The precinct index within the resolution (raster order). + internal readonly record struct PacketId(int Layer, int Res, int Comp, int Precinct); + + /// A bounded forward cursor over a byte array. + internal sealed class ByteCursor + { + /// Initializes a new instance of the class. + /// The backing array. + public ByteCursor(byte[] data) + : this(data, data.Length) + { + } + + /// Initializes a new instance of the class over a prefix of an array. + /// The backing array. + /// The number of valid bytes at the start of the array. + public ByteCursor(byte[] data, int length) + { + Data = data; + End = length; + } + + /// Creates a cursor over the written bytes of a stream without copying them. + /// A stream created with its own expandable buffer. + /// The cursor. + public static ByteCursor FromStream(MemoryStream stream) => new(stream.GetBuffer(), (int)stream.Length); + + /// Gets the backing array. + public byte[] Data { get; } + + /// Gets the exclusive end offset. + public int End { get; } + + /// Gets or sets the current offset. + public int Pos { get; set; } + + /// Gets the number of unread bytes. + public int Remaining => End - Pos; + } + + /// Reads packet header bits with the 0xFF bit-stuffing rule of ITU-T T.800 B.10.1. + internal sealed class PacketBitReader + { + private readonly ByteCursor _cursor; + private int _current; + private int _bitsLeft; + private int _last; + + /// Initializes a new instance of the class. + /// The cursor to read header bytes from. + public PacketBitReader(ByteCursor cursor) => _cursor = cursor; + + /// Reads one bit. + /// 0 or 1. + public int ReadBit() + { + if (_bitsLeft == 0) + { + if (_cursor.Pos >= _cursor.End) + { + throw Malformed("packet header is truncated."); + } + + // After a 0xFF byte the next byte carries only seven bits (its MSB is a stuffed zero). + _bitsLeft = _last == 0xFF ? 7 : 8; + _current = _cursor.Data[_cursor.Pos++]; + _last = _current; + } + + _bitsLeft--; + return (_current >> _bitsLeft) & 1; + } + + /// Reads bits, most significant first. + /// The number of bits, 0 to 31. + /// The value. + public int ReadBits(int count) + { + var v = 0; + for (var i = 0; i < count; i++) + { + v = (v << 1) | ReadBit(); + } + + return v; + } + + /// Finishes the header: discards unread bits and consumes a stuffed byte after a trailing 0xFF. + public void Align() + { + _bitsLeft = 0; + if (_last == 0xFF) + { + if (_cursor.Pos >= _cursor.End) + { + throw Malformed("packet header is truncated."); + } + + _cursor.Pos++; + } + + _last = 0; + } + } + + /// A tag tree decoder (ITU-T T.800 B.10.2). + internal sealed class TagTree + { + private readonly int[] _value; + private readonly int[] _low; + private readonly int[] _levelOffset; + private readonly int[] _levelWidth; + + /// Initializes a new instance of the class. + /// The number of leaf columns. + /// The number of leaf rows. + public TagTree(int width, int height) + { + var offsets = new List(); + var widths = new List(); + var total = 0; + var w = width; + var h = height; + while (true) + { + offsets.Add(total); + widths.Add(w); + total += w * h; + if (w == 1 && h == 1) + { + break; + } + + w = (w + 1) / 2; + h = (h + 1) / 2; + } + + _levelOffset = [.. offsets]; + _levelWidth = [.. widths]; + _value = new int[total]; + _low = new int[total]; + Array.Fill(_value, int.MaxValue); + } + + /// Decodes whether the leaf value is strictly below . + /// The packet header bit reader. + /// The leaf column. + /// The leaf row. + /// The threshold. + /// when the leaf value is known to be below the threshold. + public bool DecodeBelow(PacketBitReader br, int x, int y, int threshold) + { + var low = 0; + var node = 0; + for (var level = _levelOffset.Length - 1; level >= 0; level--) + { + node = _levelOffset[level] + ((y >> level) * _levelWidth[level]) + (x >> level); + if (low > _low[node]) + { + _low[node] = low; + } + else + { + low = _low[node]; + } + + while (low < threshold && low < _value[node]) + { + if (br.ReadBit() == 1) + { + _value[node] = low; + } + else + { + low++; + } + } + + _low[node] = low; + } + + return _value[node] < threshold; + } + } + + // ------------------------------------------------------------------------------------------ + // Progression order iteration (Annex B.12) + // ------------------------------------------------------------------------------------------ + + /// + /// Enumerates the packets of a tile in progression order, volume by volume. Every candidate packet and + /// every position step is charged to the decode-wide , so the total work is + /// bounded no matter how many tiles or progression changes a hostile stream declares. + /// + /// The built tile geometry. + /// The image size information. + /// The number of layers. + /// The progression volumes in order. + /// The decode-wide budget. + /// The packet identifiers; a packet may appear again in a later volume and must then be skipped. + private static IEnumerable EnumeratePackets(Tile tile, SizInfo siz, int layers, List changes, DecodeBudget budget) + { + var maxRes = 0; + foreach (var tc in tile.Comps) + { + maxRes = Math.Max(maxRes, tc.Res.Length); + } + + // Smallest precinct step on the reference grid, used by the position-based progressions. + var dx = long.MaxValue; + var dy = long.MaxValue; + for (var c = 0; c < tile.Comps.Length; c++) + { + var tc = tile.Comps[c]; + for (var r = 0; r < tc.Res.Length; r++) + { + var lvl = tc.Coding.Levels - r; + dx = Math.Min(dx, (long)siz.XR[c] << (tc.Res[r].PPx + lvl)); + dy = Math.Min(dy, (long)siz.YR[c] << (tc.Res[r].PPy + lvl)); + } + } + + // A volume is the set of packets (layer, resolution, component, all precincts) it covers. A volume + // lying inside an earlier one yields only packets that were already sent, so it is skipped up front. + var done = new List<(int ResStart, int CompStart, int LayerEnd, int ResEnd, int CompEnd)>(); + foreach (var ch in changes) + { + var layerEnd = Math.Min(ch.LayerEnd, layers); + var resEnd = Math.Min(ch.ResEnd, maxRes); + var compEnd = Math.Min(ch.CompEnd, tile.Comps.Length); + if (ch.ResStart >= resEnd || ch.CompStart >= compEnd) + { + continue; + } + + var covered = false; + foreach (var d in done) + { + if (ch.ResStart >= d.ResStart && ch.CompStart >= d.CompStart && layerEnd <= d.LayerEnd + && resEnd <= d.ResEnd && compEnd <= d.CompEnd) + { + covered = true; + break; + } + } + + if (covered) + { + continue; + } + + done.Add((ch.ResStart, ch.CompStart, layerEnd, resEnd, compEnd)); + IEnumerable sequence = ch.Order switch + { + 0 => EnumerateLrcp(tile, ch, layerEnd, resEnd, compEnd, budget), + 1 => EnumerateRlcp(tile, ch, layerEnd, resEnd, compEnd, budget), + _ => EnumeratePositional(tile, siz, ch, layerEnd, resEnd, compEnd, dx, dy, budget), + }; + foreach (var id in sequence) + { + yield return id; + } + } + } + + private static IEnumerable EnumerateLrcp(Tile tile, ProgressionChange ch, int layerEnd, int resEnd, int compEnd, DecodeBudget budget) + { + for (var l = 0; l < layerEnd; l++) + { + for (var r = ch.ResStart; r < resEnd; r++) + { + for (var c = ch.CompStart; c < compEnd; c++) + { + budget.ChargeProgression(1); + if (r >= tile.Comps[c].Res.Length) + { + continue; + } + + var n = tile.Comps[c].Res[r].Precincts.Length; + budget.ChargeProgression(n); + for (var k = 0; k < n; k++) + { + yield return new PacketId(l, r, c, k); + } + } + } + } + } + + private static IEnumerable EnumerateRlcp(Tile tile, ProgressionChange ch, int layerEnd, int resEnd, int compEnd, DecodeBudget budget) + { + for (var r = ch.ResStart; r < resEnd; r++) + { + for (var l = 0; l < layerEnd; l++) + { + for (var c = ch.CompStart; c < compEnd; c++) + { + budget.ChargeProgression(1); + if (r >= tile.Comps[c].Res.Length) + { + continue; + } + + var n = tile.Comps[c].Res[r].Precincts.Length; + budget.ChargeProgression(n); + for (var k = 0; k < n; k++) + { + yield return new PacketId(l, r, c, k); + } + } + } + } + } + + /// + /// Finds the precinct of component resolution whose + /// reference-grid anchor is exactly the position (, ). + /// + private static bool TryFindPrecinct(Tile tile, SizInfo siz, int c, int r, long x, long y, out int precinct) + { + precinct = 0; + var tc = tile.Comps[c]; + if (r >= tc.Res.Length) + { + return false; + } + + var res = tc.Res[r]; + if (res.Pw == 0 || res.Ph == 0) + { + return false; + } + + var lvl = tc.Coding.Levels - r; + long xr = siz.XR[c]; + long yr = siz.YR[c]; + var rpx = res.PPx + lvl; + var rpy = res.PPy + lvl; + var yOk = (y % (yr << rpy)) == 0 || (y == tile.Y0 && ((res.Y0 << lvl) % (1L << rpy)) != 0); + var xOk = (x % (xr << rpx)) == 0 || (x == tile.X0 && ((res.X0 << lvl) % (1L << rpx)) != 0); + if (!yOk || !xOk) + { + return false; + } + + var pi = (CeilDiv(x, xr << lvl) >> res.PPx) - (res.X0 >> res.PPx); + var pj = (CeilDiv(y, yr << lvl) >> res.PPy) - (res.Y0 >> res.PPy); + if (pi < 0 || pi >= res.Pw || pj < 0 || pj >= res.Ph) + { + return false; + } + + precinct = (int)(pi + (pj * res.Pw)); + return true; + } + + private static IEnumerable EnumeratePositional( + Tile tile, SizInfo siz, ProgressionChange ch, int layerEnd, int resEnd, int compEnd, long dx, long dy, DecodeBudget budget) + { + // RPCL = 2, PCRL = 3, CPRL = 4: the outer loops differ, the position loops are shared. + if (ch.Order == 2) + { + for (var r = ch.ResStart; r < resEnd; r++) + { + for (var y = tile.Y0; y < tile.Y1; y += dy - (y % dy)) + { + for (var x = tile.X0; x < tile.X1; x += dx - (x % dx)) + { + for (var c = ch.CompStart; c < compEnd; c++) + { + budget.ChargeProgression(1); + if (!TryFindPrecinct(tile, siz, c, r, x, y, out var k)) + { + continue; + } + + budget.ChargeProgression(layerEnd); + for (var l = 0; l < layerEnd; l++) + { + yield return new PacketId(l, r, c, k); + } + } + } + } + } + } + else if (ch.Order == 3) + { + for (var y = tile.Y0; y < tile.Y1; y += dy - (y % dy)) + { + for (var x = tile.X0; x < tile.X1; x += dx - (x % dx)) + { + for (var c = ch.CompStart; c < compEnd; c++) + { + for (var r = ch.ResStart; r < resEnd; r++) + { + budget.ChargeProgression(1); + if (!TryFindPrecinct(tile, siz, c, r, x, y, out var k)) + { + continue; + } + + budget.ChargeProgression(layerEnd); + for (var l = 0; l < layerEnd; l++) + { + yield return new PacketId(l, r, c, k); + } + } + } + } + } + } + else + { + for (var c = ch.CompStart; c < compEnd; c++) + { + for (var y = tile.Y0; y < tile.Y1; y += dy - (y % dy)) + { + for (var x = tile.X0; x < tile.X1; x += dx - (x % dx)) + { + for (var r = ch.ResStart; r < resEnd; r++) + { + budget.ChargeProgression(1); + if (!TryFindPrecinct(tile, siz, c, r, x, y, out var k)) + { + continue; + } + + budget.ChargeProgression(layerEnd); + for (var l = 0; l < layerEnd; l++) + { + yield return new PacketId(l, r, c, k); + } + } + } + } + } + } + } + + // ------------------------------------------------------------------------------------------ + // Packet parsing (Annex B.10) + // ------------------------------------------------------------------------------------------ + + /// Reads every packet of a tile in progression order, filling the code-block segment data. + /// The built tile geometry. + /// The coding state of the tile. + /// The image size information. + /// The concatenated tile-part bodies. + /// The packet-header cursor: the packed headers when PPM/PPT are used, otherwise . + /// The decode-wide budget. + private static void ReadTilePackets(Tile tile, CodingState st, SizInfo siz, ByteCursor body, ByteCursor headers, DecodeBudget budget) + { + + // Per (component, resolution) base index so each packet has a unique slot in the "seen" bitmap. + var bases = new long[tile.Comps.Length][]; + long running = 0; + for (var c = 0; c < tile.Comps.Length; c++) + { + bases[c] = new long[tile.Comps[c].Res.Length]; + for (var r = 0; r < bases[c].Length; r++) + { + bases[c][r] = running; + running += tile.Comps[c].Res[r].Precincts.Length; + } + } + + var layers = st.Layers; + var seen = new bool[running * layers]; + var changes = st.Poc ?? [new ProgressionChange(0, 0, layers, 33, tile.Comps.Length, st.Progression)]; + var pending = new List<(CodeBlock Block, Segment Seg, int Length)>(); + long covered = 0; + foreach (var id in EnumeratePackets(tile, siz, layers, changes, budget)) + { + var slot = ((bases[id.Comp][id.Res] + id.Precinct) * layers) + id.Layer; + if (seen[slot]) + { + continue; + } + + seen[slot] = true; + covered++; + var precinct = tile.Comps[id.Comp].Res[id.Res].Precincts[id.Precinct]; + ReadPacket(precinct, tile.Comps[id.Comp].Coding.Style, id.Layer, st, body, headers, pending); + } + + // Every packet of the tile must be sent exactly once (B.12): progression volumes that leave packets + // uncovered would otherwise silently drop code-blocks and produce a partial image. + if (covered != tile.TotalPackets) + { + throw Malformed("the progression order does not cover every packet of the tile."); + } + } + + private static void ReadPacket( + Precinct precinct, int style, int layer, CodingState st, ByteCursor body, ByteCursor headers, + List<(CodeBlock Block, Segment Seg, int Length)> pending) + { + // Optional SOP marker segment in front of the packet (always in the body stream). Deliberately not required: + // the Scod SOP bit says markers MAY be present (T.800 A.6.1), so a missing or unnumbered SOP is accepted, as + // OpenJPEG does (it only warns). A SOP that is present must have Lsop = 4. + if (st.Sop && body.Remaining >= 6 && body.Data[body.Pos] == 0xFF && body.Data[body.Pos + 1] == 0x91) + { + if (body.Data[body.Pos + 2] != 0 || body.Data[body.Pos + 3] != 4) + { + throw Malformed("invalid SOP marker segment length."); + } + + body.Pos += 6; + } + + var br = new PacketBitReader(headers); + pending.Clear(); + if (br.ReadBit() == 1) + { + foreach (var pb in precinct.Bands) + { + ReadBandContributions(pb, style, layer, br, pending); + } + } + + br.Align(); + if (st.Eph) + { + if (headers.Remaining < 2 || headers.Data[headers.Pos] != 0xFF || headers.Data[headers.Pos + 1] != 0x92) + { + throw Malformed("missing EPH marker."); + } + + headers.Pos += 2; + } + + // Packet body: the coded bytes of each contribution, in header order. + foreach (var (block, seg, length) in pending) + { + if (length > body.Remaining) + { + throw Malformed("packet body extends beyond the tile data."); + } + + if (length > 0) + { + if (block.DataLength + length > block.Data.Length) + { + var grown = block.Data; + Array.Resize(ref grown, Math.Max(block.DataLength + length, grown.Length * 2)); + block.Data = grown; + } + + Array.Copy(body.Data, body.Pos, block.Data, block.DataLength, length); + block.DataLength += length; + body.Pos += length; + } + + seg.Length += length; + } + } + + private static void ReadBandContributions( + PrecinctBand pb, int style, int layer, PacketBitReader br, List<(CodeBlock Block, Segment Seg, int Length)> pending) + { + for (var idx = 0; idx < pb.Blocks.Length; idx++) + { + var cb = pb.Blocks[idx]; + var cx = idx % pb.Cw; + var cy = idx / pb.Cw; + + // Inclusion: tag tree for blocks never included, a single bit afterwards. + bool included; + if (cb.Included) + { + included = br.ReadBit() == 1; + } + else + { + included = pb.Inclusion!.DecodeBelow(br, cx, cy, layer + 1); + } + + if (!included) + { + continue; + } + + if (!cb.Included) + { + // Number of missing most significant bit-planes: decode the leaf value fully. + var i = 1; + // The count cannot reach TopPlanes: the block would have no bit-plane left to code. + while (!pb.ZeroBits!.DecodeBelow(br, cx, cy, i)) + { + if (++i > pb.TopPlanes) + { + throw Malformed("zero bit-plane count is out of range."); + } + } + + cb.ZeroBitPlanes = i - 1; + cb.Included = true; + cb.Lblock = 3; + } + + var passes = ReadPassCount(br); + while (br.ReadBit() == 1) + { + if (++cb.Lblock > 32) + { + throw Malformed("code-block length indicator is out of range."); + } + } + + // A block can never carry more coding passes than its bit-planes allow. TopPlanes was validated + // against MaxBitPlanes during geometry construction and ZeroBitPlanes is below it. + var planes = pb.TopPlanes - cb.ZeroBitPlanes; + if (cb.TotalPasses + passes > (3 * planes) - 2) + { + throw Malformed("code-block pass count exceeds its bit-planes."); + } + + cb.TotalPasses += passes; + SplitIntoSegments(cb, style, passes, br, pending); + } + } + + private static int ReadPassCount(PacketBitReader br) + { + if (br.ReadBit() == 0) + { + return 1; + } + + if (br.ReadBit() == 0) + { + return 2; + } + + var n = br.ReadBits(2); + if (n != 3) + { + return 3 + n; + } + + n = br.ReadBits(5); + return n != 31 ? 6 + n : 37 + br.ReadBits(7); + } + + private static Segment NewSegment(CodeBlock cb, int style) + { + // TERMALL: one pass per segment. LAZY (bypass): 10 passes, then alternating 2 (raw) and 1 (MQ). + int max; + if ((style & 0x04) != 0) + { + max = 1; + } + else if ((style & 0x01) != 0) + { + if (cb.Segments.Count == 0) + { + max = 10; + } + else + { + var prev = cb.Segments[^1].MaxPasses; + max = prev == 1 || prev == 10 ? 2 : 1; + } + } + else + { + max = 109; + } + + var seg = new Segment { MaxPasses = max }; + cb.Segments.Add(seg); + return seg; + } + + private static void SplitIntoSegments( + CodeBlock cb, int style, int passes, PacketBitReader br, List<(CodeBlock Block, Segment Seg, int Length)> pending) + { + var seg = cb.Segments.Count == 0 ? NewSegment(cb, style) : cb.Segments[^1]; + if (seg.Passes >= seg.MaxPasses) + { + seg = NewSegment(cb, style); + } + + var remaining = passes; + while (remaining > 0) + { + var take = Math.Min(seg.MaxPasses - seg.Passes, remaining); + var bits = cb.Lblock + (31 - int.LeadingZeroCount(take)); + if (bits > 31) + { + throw Malformed("code-block length field is too wide."); + } + + var length = br.ReadBits(bits); + seg.Passes += take; + pending.Add((cb, seg, length)); + remaining -= take; + if (remaining > 0) + { + seg = NewSegment(cb, style); + } + } + } +} diff --git a/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Probe.cs b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Probe.cs new file mode 100644 index 00000000..085fda6a --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Probe.cs @@ -0,0 +1,345 @@ +namespace DemaConsulting.CanvasNet.Codecs; + +public static partial class Jpeg2000Codec +{ + // ================================================================================================ + // Incremental header probing for GetInfo + // ================================================================================================ + + /// The largest SOC + SIZ prefix: 2 (SOC) + 2 (marker) + the 16-bit length field value (at most 65535). + private const int MaxSizPrefix = 4 + 65535; + + /// The chunk size used when discarding the content of a skipped box on a non-seekable stream. + private const int DiscardChunk = 8192; + + /// + /// The most bytes read for one jp2h child box. The largest valid child is a palette + /// (3 + 255 + 1024 x 255 x 2 = about 510 KiB), so no valid file exceeds this; a larger palette, component + /// mapping or channel definition box is malformed, and a larger color specification box is only read in part. + /// + private const int MaxProbeChildBytes = 1024 * 1024; + + /// The leading bytes of a colr box that are read: the method, two reserved bytes, and the start of the ICC profile. + private const int ProbeColrBytes = 256; + + /// + /// A forward-only view of the stream that counts the bytes consumed and enforces the input-size cap + /// () without ever buffering more than the caller asks for. + /// + private sealed class ProbeReader + { + private readonly Stream _stream; + private readonly long _cap; + + /// Initializes a new instance of the class. + /// The stream; reading starts at its current position. + /// The maximum number of bytes the reader may consume. + public ProbeReader(Stream stream, long cap) + { + _stream = stream; + _cap = cap; + + // A seekable stream reveals its remaining length up front, so oversized input and boxes that + // overrun the data are rejected exactly like the whole-buffer path would. + Remaining = stream.CanSeek ? Math.Max(0, stream.Length - stream.Position) : -1; + if (Remaining > cap) + { + throw Malformed("input is too large."); + } + } + + /// Gets the number of bytes consumed so far. + public long Consumed { get; private set; } + + /// Gets the number of bytes left, or -1 when the stream cannot report it. + public long Remaining { get; private set; } + + /// Gets the number of bytes that may still be consumed under the cap. + public long CapLeft => _cap - Consumed; + + /// Reads up to bytes, fewer only at the end of the stream. + /// The number of bytes wanted. + /// The bytes read. + public byte[] Read(int count) + { + // Grow in chunks so a hostile declared length cannot force a large allocation before any byte arrives. + using var memory = new MemoryStream(); + var chunk = new byte[Math.Min(count, DiscardChunk)]; + var got = 0; + while (got < count) + { + var n = _stream.Read(chunk, 0, Math.Min(chunk.Length, count - got)); + if (n <= 0) + { + break; + } + + memory.Write(chunk, 0, n); + got += n; + } + + Account(got); + return memory.ToArray(); + } + + /// Skips bytes. + /// The number of bytes to skip. + /// when all bytes were skipped; when the stream ended first. + public bool Skip(long count) + { + if (count > CapLeft) + { + throw Malformed("input is too large."); + } + + if (_stream.CanSeek) + { + if (count > Remaining) + { + return false; + } + + _stream.Seek(count, SeekOrigin.Current); + Account(count); + return true; + } + + var chunk = new byte[DiscardChunk]; + var left = count; + while (left > 0) + { + var n = _stream.Read(chunk, 0, (int)Math.Min(left, chunk.Length)); + if (n <= 0) + { + Account(count - left); + return false; + } + + left -= n; + } + + Account(count); + return true; + } + + private void Account(long n) + { + Consumed += n; + if (Remaining >= 0) + { + Remaining -= n; + } + + if (Consumed > _cap) + { + throw Malformed("input is too large."); + } + } + } + + /// + /// Reads only the part of a raw codestream or JP2 file that needs: the JP2 + /// boxes up to the codestream box, plus the SOC and SIZ marker segments. The existing box and marker parsers + /// run on the bytes read. + /// + /// The stream to read from. + /// The limits supplying the input-size cap. + /// Receives the validated SIZ information. + /// The container information (an empty one for a raw codestream). + private static Jp2Info ProbeHeaders(Stream stream, Jpeg2000DecoderLimits limits, out SizInfo siz) + { + var reader = new ProbeReader(stream, limits.MaxInputBytes); + var first = reader.Read(12); + if (first.Length >= 2 && first[0] == 0xFF && first[1] == 0x4F) + { + // Raw codestream: the SOC and SIZ segments are the whole prefix; a shorter stream is passed as is. + var prefix = first; + if (first.Length == 12) + { + var rest = reader.Read(MaxSizPrefix - 12); + prefix = new byte[12 + rest.Length]; + first.CopyTo(prefix, 0); + rest.CopyTo(prefix, 12); + } + + siz = Codestream.ParseSizOnly(prefix, 0, prefix.Length, out _); + return new Jp2Info { CodestreamStart = 0, CodestreamEnd = prefix.Length }; + } + + // The signature checks of ParseContainer. + if (first.Length < 12 || ReadBe32(first, 0) != 12 || ReadBe32(first, 4) != BoxJp2Signature) + { + throw Malformed("data is neither a JPEG 2000 codestream nor a JP2 file."); + } + + if (ReadBe32(first, 8) != 0x0D0A870A) + { + throw Malformed("invalid JP2 signature box content."); + } + + var info = new Jp2Info(); + var ftyp = ReadProbeBoxHeader(reader, out var ftypToEnd, out var ftypAtEnd); + if (ftypAtEnd) + { + throw Malformed("the file type box must follow the JP2 signature box."); + } + + CheckFileTypeBox(ftyp); + if (ftypToEnd || !reader.Skip(ftyp.End - ftyp.ContentStart)) + { + throw Malformed("JP2 file has no codestream box."); + } + + for (var count = 0; count < MaxBoxes; count++) + { + var header = ReadProbeBoxHeader(reader, out var toEnd, out var atEnd); + if (atEnd) + { + break; + } + + var contentLength = header.End - header.ContentStart; + if (header.Type == BoxCodestream) + { + CheckHeaderBeforeCodestream(info); + + // Only the SOC and SIZ segments are needed, whatever the length of the codestream. + var take = Math.Min(MaxSizPrefix, toEnd ? int.MaxValue : contentLength); + var prefix = reader.Read(take); + info.CodestreamStart = 0; + info.CodestreamEnd = prefix.Length; + siz = Codestream.ParseSizOnly(prefix, 0, prefix.Length, out _); + CheckIhdrAgainstSiz(info, siz); + return info; + } + + if (header.Type == BoxJp2Header) + { + // The header box is walked child by child: only the child headers and the small boxes are read. + info.HasHeaderBox = true; + ProbeHeaderBox(reader, contentLength, toEnd, info); + if (toEnd) + { + break; + } + } + else if (toEnd || !reader.Skip(contentLength)) + { + if (!toEnd) + { + throw Malformed("invalid box length."); + } + + break; + } + } + + throw Malformed("JP2 file has no codestream box."); + } + + /// Reads one box header with the shared parser. + /// The probe reader positioned at the box. + /// Receives whether the box extends to the end of the data (a zero length field). + /// Receives whether the data ended cleanly before the box. + /// The header, relative to the start of the box (header start at offset zero). + private static BoxHeader ReadProbeBoxHeader(ProbeReader reader, out bool toEnd, out bool atEnd) + { + toEnd = false; + atEnd = false; + var head = reader.Read(8); + if (head.Length == 0) + { + atEnd = true; + return default; + } + + if (head.Length == 8 && ReadBe32(head, 0) == 1) + { + var extra = reader.Read(8); + var both = new byte[8 + extra.Length]; + head.CopyTo(both, 0); + extra.CopyTo(both, 8); + head = both; + } + + // At the end of the data the read was short, so the length is exact; otherwise it is the known remaining + // length, or (non-seekable) the most the cap still allows. A larger box is then reported as invalid. + var truncated = head.Length < 8 || (head.Length < 16 && ReadBe32(head, 0) == 1); + var known = reader.Remaining >= 0 ? reader.Remaining : reader.CapLeft; + var limit = truncated ? head.Length : known + head.Length; + toEnd = head.Length >= 4 && ReadBe32(head, 0) == 0; + var box = ReadBoxHeader(head, 0, (int)Math.Min(limit, int.MaxValue)); + return box; + } + + /// + /// Walks the children of the jp2h superbox without buffering it: each child header is read, the boxes + /// the shared parsers interpret are read (bounded by ) and parsed, and every + /// other box is skipped. A colr box is read only for its leading bytes, so a large ICC profile is + /// never buffered (only the profile header is needed to classify the color space). + /// + /// The probe reader positioned at the first child box. + /// The declared content length of jp2h, or the most the stream can hold when . + /// Whether the jp2h box extends to the end of the data. + /// Receives the parsed information. + private static void ProbeHeaderBox(ProbeReader reader, int contentLength, bool toEnd, Jp2Info info) + { + var left = (long)contentLength; + if (toEnd) + { + left = reader.Remaining >= 0 ? reader.Remaining : reader.CapLeft; + } + + for (var count = 0; count < MaxBoxes && left > 0; count++) + { + var head = reader.Read(8); + if (head.Length == 8 && ReadBe32(head, 0) == 1) + { + var extra = reader.Read(8); + var both = new byte[8 + extra.Length]; + head.CopyTo(both, 0); + extra.CopyTo(both, 8); + head = both; + } + + if (head.Length == 0 && toEnd) + { + return; + } + + var truncated = head.Length < 8 || (head.Length < 16 && ReadBe32(head, 0) == 1); + var box = ReadBoxHeader(head, 0, (int)(truncated ? head.Length : Math.Min(left, int.MaxValue))); + var content = box.End - box.ContentStart; + left -= box.End; + + var read = box.Type switch + { + BoxColr => Math.Min(content, ProbeColrBytes), + BoxIhdr => content == 14 ? 14 : throw Malformed("invalid image header box length."), + BoxPclr or BoxCmap or BoxCdef => content <= MaxProbeChildBytes ? content : throw Malformed("header box is too large."), + _ => 0, + }; + + if (box.Type is BoxColr or BoxIhdr or BoxPclr or BoxCmap or BoxCdef) + { + var body = reader.Read(read); + if (body.Length != read) + { + throw Malformed("invalid box length."); + } + + if (box.Type == BoxColr && content > read && body[0] == 1) + { + throw Malformed("color specification box has an invalid length."); + } + + ParseHeaderChild(body, new BoxHeader(box.Type, 0, body.Length), info); + } + + if (content > read && !reader.Skip(content - read)) + { + throw Malformed("invalid box length."); + } + } + } +} diff --git a/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Tier1.cs b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Tier1.cs new file mode 100644 index 00000000..bc2763c7 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000/Jpeg2000Codec.Tier1.cs @@ -0,0 +1,517 @@ +namespace DemaConsulting.CanvasNet.Codecs; + +public static partial class Jpeg2000Codec +{ + // ================================================================================================ + // Tier-1 (EBCOT) code-block decoding (ITU-T T.800 Annex D) and de-quantization (Annex E) + // ================================================================================================ + + /// Flag bit: the coefficient is significant. + private const byte FlagSig = 1; + + /// Flag bit: the coefficient was coded in the significance propagation pass of the current bit-plane. + private const byte FlagVisit = 2; + + /// Flag bit: the coefficient has been refined at least once. + private const byte FlagRefined = 4; + + /// Flag bit: the coefficient is negative. + private const byte FlagNeg = 8; + + /// Code-block style bit: selective arithmetic coding bypass. + private const int StyleBypass = 0x01; + + /// Code-block style bit: reset context probabilities on each pass. + private const int StyleReset = 0x02; + + /// Code-block style bit: termination on each coding pass. + internal const int StyleTermAll = 0x04; + + /// Code-block style bit: vertically causal context. + private const int StyleVCausal = 0x08; + + /// Code-block style bit: segmentation symbols. + private const int StyleSegSym = 0x20; + + /// Decodes code-blocks; one instance owns the scratch buffers and is reused across blocks. + internal sealed class Tier1Decoder + { + private readonly MqDecoder _mq = new(); + private readonly RawBitReader _raw = new(); + private byte[] _flags = []; + private int[] _mag = []; + private byte[] _low = []; + private int _w; + private int _h; + private int _stride; + private int _orient; + private bool _vcausal; + + /// Decodes one code-block and stores its de-quantized coefficients in a component plane. + /// The code-block with its gathered codeword segments. + /// The owning sub-band. + /// The code-block style flags. + /// The region-of-interest shift. + /// Whether the 5/3 reversible path is in use. + /// The integer plane (reversible path) or . + /// The float plane (irreversible path) or . + /// The row stride of the plane. + /// The decode-wide budget. + public void DecodeBlock( + CodeBlock block, Band band, int style, int roiShift, bool reversible, int[]? ints, float[]? floats, int planeStride, + DecodeBudget budget) + { + if (block.Segments.Count == 0 || block.TotalPasses == 0) + { + return; + } + + _w = block.X1 - block.X0; + _h = block.Y1 - block.Y0; + var numPlanes = band.Mb + roiShift - block.ZeroBitPlanes; + if (numPlanes <= 0) + { + return; + } + + // numPlanes never exceeds MaxBitPlanes: geometry construction validated Mb plus the ROI shift. + budget.ChargeTier1((long)_w * _h * block.TotalPasses); + Prepare(style, band.Orient); + RunPasses(block, style, numPlanes); + Store(block, band, roiShift, reversible, ints, floats, planeStride); + } + + private void Prepare(int style, int orient) + { + _stride = _w + 2; + var flagCount = _stride * (_h + 2); + if (_flags.Length < flagCount) + { + _flags = new byte[flagCount]; + } + else + { + Array.Clear(_flags, 0, flagCount); + } + + var n = _w * _h; + if (_mag.Length < n) + { + _mag = new int[n]; + _low = new byte[n]; + } + else + { + Array.Clear(_mag, 0, n); + Array.Clear(_low, 0, n); + } + + _orient = orient; + _vcausal = (style & StyleVCausal) != 0; + _mq.ResetContexts(); + } + + private void RunPasses(CodeBlock block, int style, int numPlanes) + { + var passIndex = 0; + var bp = numPlanes - 1; + var offset = 0; + foreach (var seg in block.Segments) + { + if (seg.Passes == 0) + { + continue; + } + + var firstType = (passIndex + 2) % 3; + var raw = (style & StyleBypass) != 0 && passIndex >= 10 && firstType != 2; + var decode = seg.Length > 0 && offset + seg.Length <= block.DataLength; + if (decode) + { + if (raw) + { + _raw.Init(block.Data, offset, seg.Length); + } + else + { + _mq.Init(block.Data, offset, seg.Length); + } + } + + for (var k = 0; k < seg.Passes; k++) + { + if (bp < 0) + { + throw Malformed("code-block has more coding passes than bit-planes."); + } + + var type = (passIndex + 2) % 3; + if (decode) + { + RunPass(type, bp, raw, style); + } + + if (type == 2) + { + bp--; + } + + passIndex++; + } + + offset += seg.Length; + } + } + + private void RunPass(int type, int bp, bool raw, int style) + { + if ((style & StyleReset) != 0) + { + _mq.ResetContexts(); + } + + switch (type) + { + case 0: + SigProp(bp, raw); + break; + case 1: + MagRef(bp, raw); + break; + default: + Cleanup(bp); + // The segmentation symbol is the four decisions 1010 on the uniform context. + if ((style & StyleSegSym) != 0 + && (_mq.DecodeBit(18) != 1 || _mq.DecodeBit(18) != 0 || _mq.DecodeBit(18) != 1 || _mq.DecodeBit(18) != 0)) + { + throw Malformed("segmentation symbol is wrong; the code-block is corrupt."); + } + + break; + } + } + + private void SigProp(int bp, bool raw) + { + for (var y0 = 0; y0 < _h; y0 += 4) + { + var y1 = Math.Min(y0 + 4, _h); + for (var x = 0; x < _w; x++) + { + for (var y = y0; y < y1; y++) + { + var idx = ((y + 1) * _stride) + x + 1; + var f = _flags[idx]; + if ((f & FlagSig) != 0) + { + continue; + } + + var ctx = ZeroContext(idx, y); + if (ctx == 0) + { + continue; + } + + int bit = raw ? _raw.ReadBit() : _mq.DecodeBit(ctx); + _flags[idx] |= FlagVisit; + if (bit != 0) + { + BecomeSignificant(idx, x, y, bp, raw); + } + } + } + } + } + + private void MagRef(int bp, bool raw) + { + for (var y0 = 0; y0 < _h; y0 += 4) + { + var y1 = Math.Min(y0 + 4, _h); + for (var x = 0; x < _w; x++) + { + for (var y = y0; y < y1; y++) + { + var idx = ((y + 1) * _stride) + x + 1; + var f = _flags[idx]; + if ((f & FlagSig) == 0 || (f & FlagVisit) != 0) + { + continue; + } + + int bit; + if (raw) + { + bit = _raw.ReadBit(); + } + else + { + int ctx; + if ((f & FlagRefined) != 0) + { + ctx = 16; + } + else + { + ctx = HasSignificantNeighbor(idx, y) ? 15 : 14; + } + + bit = _mq.DecodeBit(ctx); + } + + var m = (y * _w) + x; + _mag[m] |= bit << bp; + _low[m] = (byte)bp; + _flags[idx] |= FlagRefined; + } + } + } + } + + private void Cleanup(int bp) + { + for (var y0 = 0; y0 < _h; y0 += 4) + { + var full = y0 + 4 <= _h; + for (var x = 0; x < _w; x++) + { + var start = y0; + if (full && RunLengthEligible(x, y0)) + { + if (_mq.DecodeBit(17) == 0) + { + continue; + } + + var run = (_mq.DecodeBit(18) << 1) | _mq.DecodeBit(18); + var y = y0 + run; + BecomeSignificant(((y + 1) * _stride) + x + 1, x, y, bp, false); + start = y + 1; + } + + for (var y = start; y < Math.Min(y0 + 4, _h); y++) + { + var idx = ((y + 1) * _stride) + x + 1; + if ((_flags[idx] & (FlagSig | FlagVisit)) != 0) + { + continue; + } + + if (_mq.DecodeBit(ZeroContext(idx, y)) != 0) + { + BecomeSignificant(idx, x, y, bp, false); + } + } + } + } + + // The visit marks only live for one bit-plane. + for (var y = 0; y < _h; y++) + { + var row = ((y + 1) * _stride) + 1; + for (var x = 0; x < _w; x++) + { + _flags[row + x] &= unchecked((byte)~FlagVisit); + } + } + } + + private bool RunLengthEligible(int x, int y0) + { + for (var k = 0; k < 4; k++) + { + var y = y0 + k; + var idx = ((y + 1) * _stride) + x + 1; + if ((_flags[idx] & (FlagSig | FlagVisit)) != 0 || ZeroContext(idx, y) != 0) + { + return false; + } + } + + return true; + } + + private void BecomeSignificant(int idx, int x, int y, int bp, bool raw) + { + int sign; + if (raw) + { + sign = _raw.ReadBit(); + } + else + { + sign = SignBit(idx, y); + } + + _flags[idx] |= (byte)(FlagSig | (sign != 0 ? FlagNeg : 0)); + var m = (y * _w) + x; + _mag[m] |= 1 << bp; + _low[m] = (byte)bp; + } + + private bool NoSouth(int y) => _vcausal && (y & 3) == 3; + + private bool HasSignificantNeighbor(int idx, int y) => ZeroContext(idx, y) != 0; + + private int ZeroContext(int idx, int y) + { + var f = _flags; + var s = _stride; + var h = (f[idx - 1] & FlagSig) + (f[idx + 1] & FlagSig); + var v = f[idx - s] & FlagSig; + var d = (f[idx - s - 1] & FlagSig) + (f[idx - s + 1] & FlagSig); + if (!NoSouth(y)) + { + v += f[idx + s] & FlagSig; + d += (f[idx + s - 1] & FlagSig) + (f[idx + s + 1] & FlagSig); + } + + return _orient switch + { + 3 => HighHighContext(h + v, d), + 1 => LowHighContext(v, h, d), + _ => LowHighContext(h, v, d), + }; + } + + private static int LowHighContext(int primary, int secondary, int d) + { + if (primary == 2) + { + return 8; + } + + if (primary == 1) + { + if (secondary >= 1) + { + return 7; + } + + return d >= 1 ? 6 : 5; + } + + if (secondary == 2) + { + return 4; + } + + if (secondary == 1) + { + return 3; + } + + return d >= 2 ? 2 : d; + } + + private static int HighHighContext(int hv, int d) + { + if (d >= 3) + { + return 8; + } + + if (d == 2) + { + return hv >= 1 ? 7 : 6; + } + + if (d == 1) + { + if (hv >= 2) + { + return 5; + } + + return hv == 1 ? 4 : 3; + } + + return hv >= 2 ? 2 : hv; + } + + private int Contribution(int idx) + { + var f = _flags[idx]; + if ((f & FlagSig) == 0) + { + return 0; + } + + return (f & FlagNeg) != 0 ? -1 : 1; + } + + private int SignBit(int idx, int y) + { + var hc = Math.Clamp(Contribution(idx - 1) + Contribution(idx + 1), -1, 1); + var vcSum = Contribution(idx - _stride); + if (!NoSouth(y)) + { + vcSum += Contribution(idx + _stride); + } + + var vc = Math.Clamp(vcSum, -1, 1); + int ctx; + int xor = 0; + if (hc == 1) + { + ctx = 12 + vc; + } + else if (hc == 0) + { + if (vc == 0) + { + ctx = 9; + } + else + { + ctx = 10; + xor = vc == -1 ? 1 : 0; + } + } + else + { + ctx = 12 - vc; + xor = 1; + } + + return _mq.DecodeBit(ctx) ^ xor; + } + + private void Store(CodeBlock block, Band band, int roiShift, bool reversible, int[]? ints, float[]? floats, int planeStride) + { + for (var y = 0; y < _h; y++) + { + var rowBase = ((band.OffY + block.Y0 + y) * planeStride) + band.OffX + block.X0; + for (var x = 0; x < _w; x++) + { + var m = (y * _w) + x; + var mag = _mag[m]; + if (mag == 0) + { + continue; + } + + var low = (int)_low[m]; + if (roiShift > 0 && mag >= (1 << Math.Min(roiShift, 30))) + { + mag >>= Math.Min(roiShift, 31); + low = Math.Max(0, low - Math.Min(roiShift, 31)); + } + + var neg = (_flags[((y + 1) * _stride) + x + 1] & FlagNeg) != 0; + if (reversible) + { + var v = low > 0 ? mag + (1 << (low - 1)) : mag; + ints![rowBase + x] = neg ? -v : v; + } + else + { + var v = (mag + (0.5f * (1 << low))) * band.Step; + floats![rowBase + x] = neg ? -v : v; + } + } + } + } + } +} diff --git a/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000Codec.cs b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000Codec.cs new file mode 100644 index 00000000..5ec010c1 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000Codec.cs @@ -0,0 +1,327 @@ +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Codecs; + +/// +/// Decodes JPEG 2000 images (ITU-T T.800 | ISO/IEC 15444-1) from raw codestreams (.j2k/.j2c) +/// and from JP2 files (.jp2) into a or a . +/// +/// +/// +/// This is a decode-only, dependency-free implementation of the complete Part 1 decoder: all five +/// progression orders, progression order changes, SOP/EPH markers, packed packet headers (PPM/PPT), +/// tiles and tile-parts, all precinct and code-block configurations, every code-block style +/// (bypass, reset, termination on each pass, vertically causal contexts, segmentation symbols), +/// region-of-interest max-shift, the reversible 5/3 and irreversible 9/7 wavelets, the reversible and +/// irreversible component transforms, subsampled components, signed and 1 to 16 bit samples, and +/// the JP2 color specification, palette, component mapping, channel definition boxes. +/// +/// +/// Samples are scaled to 8 bits per channel. ICC profiles are reported by +/// but never applied. Part 2 extensions, high-throughput (HTJ2K) +/// codestreams, more than 16 components and samples deeper than 16 bits are rejected with +/// . +/// +/// +/// The decoder fails closed: malformed or incomplete input (including a codestream that lacks tiles) +/// raises , and inputs that would need unreasonable memory or +/// time (decompression bombs) are rejected with it as well; the bounds are the +/// , checked against the headers before anything is allocated. +/// There is no recovery or partial decoding. Every structural field is validated where it is +/// parsed; a last-resort backstop converts an arithmetic or index fault that slipped through +/// into as well, so no other exception type escapes on bad input. +/// +/// +/// Four color channels are interpreted as CMYK only when the file says so (a JP2 color +/// specification of CMYK) or, for a raw codestream or a JP2 file without any color specification, +/// as a documented heuristic. Four channels in any other declared color space are rejected with +/// instead of being guessed. +/// +/// +public static partial class Jpeg2000Codec +{ + /// + /// Loads a from a JPEG 2000 codestream or JP2 file. + /// + /// + /// The stream to read from. Reading begins at the current position and consumes the remainder of the stream. + /// + /// + /// A new . Gray images are expanded to R=G=B, CMYK images are converted to RGB with + /// a simple device conversion, and images without alpha are fully opaque. + /// + /// Thrown when is null. + /// + /// Thrown when the data is malformed, exceeds the decoder resource limits, or is wider or taller than + /// . + /// + /// Thrown when the file uses a feature the decoder does not support. + public static Surface Load(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + + var image = Decode(stream); + return ToSurface(image); + } + + /// + /// Loads a from a JPEG 2000 file at the specified path. + /// + /// The path of the file. Must not be null or empty. + /// A new ; see . + /// Thrown when is null. + /// Thrown when is an empty string. + /// Thrown for the same conditions as . + /// Thrown for the same conditions as . + /// File-system exceptions raised while opening propagate to the caller. + public static Surface Load(string path) + { + ArgumentNullException.ThrowIfNull(path); + if (path.Length == 0) + { + throw new ArgumentException("Path must not be empty.", nameof(path)); + } + + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read); + return Load(stream); + } + + /// + /// Reports the dimensions and channel layout of a JPEG 2000 image without decoding any tile data. Only the + /// header is read: the JP2 boxes up to the codestream box and the SOC and SIZ marker segments, never the + /// codestream body. + /// + /// + /// The stream to read from. Reading begins at the current position and stops once the header has been read; + /// the position afterwards is unspecified. A seekable stream skips unneeded boxes by seeking; a non-seekable + /// stream discards them, so such a box is read (up to the 256 MiB input cap) but never buffered. + /// + /// + /// An whose counts the color channels plus the + /// alpha channel, if any. is not enforced. + /// + /// Thrown when is null. + /// Thrown when the container or SIZ marker segment is malformed. + /// Thrown when the file uses a feature the decoder does not support. + public static ImageInfo GetInfo(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + + return Guard(() => + { + var jp2 = ProbeHeaders(stream, Jpeg2000DecoderLimits.Default, out var siz); + var layout = ResolveLayout(jp2, siz.Csiz); + var hasAlpha = layout.Alpha is not null; + return new ImageInfo((int)Math.Min(siz.Width, int.MaxValue), (int)Math.Min(siz.Height, int.MaxValue), layout.Color.Length + (hasAlpha ? 1 : 0), hasAlpha); + }); + } + + /// + /// Reports the dimensions and channel layout of the JPEG 2000 file at the specified path. + /// + /// The path of the file. Must not be null or empty. + /// An ; see . + /// Thrown when is null. + /// Thrown when is an empty string. + /// Thrown for the same conditions as . + /// Thrown for the same conditions as . + /// File-system exceptions raised while opening propagate to the caller. + public static ImageInfo GetInfo(string path) + { + ArgumentNullException.ThrowIfNull(path); + if (path.Length == 0) + { + throw new ArgumentException("Path must not be empty.", nameof(path)); + } + + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read); + return GetInfo(stream); + } + + /// + /// Decodes a JPEG 2000 codestream or JP2 file to 8-bit component samples without converting to a . + /// + /// + /// The stream to read from. Reading begins at the current position and consumes the remainder of the stream. + /// + /// The decoded image with its color space, alpha plane and ICC profile. + /// Thrown when is null. + /// + /// Thrown when the data is malformed, exceeds the decoder resource limits, or is wider or taller than + /// . + /// + /// Thrown when the file uses a feature the decoder does not support. + public static Jpeg2000Image Decode(Stream stream) => Decode(stream, Jpeg2000DecoderLimits.Default); + + /// + /// Decodes a JPEG 2000 codestream or JP2 file to 8-bit component samples, enforcing the given limits. + /// + /// + /// The stream to read from. Reading begins at the current position and consumes the remainder of the stream. + /// + /// The resource limits to enforce. + /// The decoded image; see . + /// Thrown when or is null. + /// Thrown when a limit is not positive or is too large. + /// Thrown when the data is malformed or exceeds . + /// Thrown when the file uses a feature the decoder does not support. + public static Jpeg2000Image Decode(Stream stream, Jpeg2000DecoderLimits limits) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentNullException.ThrowIfNull(limits); + limits.Validate(); + + return Decode(ReadAllBytes(stream, limits), limits); + } + + /// + /// Decodes a JPEG 2000 codestream or JP2 file held in memory. + /// + /// The file or codestream bytes. + /// The decoded image; see . + /// Thrown when is null. + /// Thrown for the same conditions as . + /// Thrown for the same conditions as . + public static Jpeg2000Image Decode(byte[] data) => Decode(data, Jpeg2000DecoderLimits.Default); + + /// + /// Decodes a JPEG 2000 codestream or JP2 file held in memory, enforcing the given limits. + /// + /// The file or codestream bytes. + /// The resource limits to enforce. + /// The decoded image; see . + /// Thrown when or is null. + /// Thrown when a limit is not positive or is too large. + /// Thrown when the data is malformed or exceeds . + /// Thrown when the file uses a feature the decoder does not support. + public static Jpeg2000Image Decode(byte[] data, Jpeg2000DecoderLimits limits) + { + ArgumentNullException.ThrowIfNull(data); + ArgumentNullException.ThrowIfNull(limits); + limits.Validate(); + + if (data.Length > limits.MaxInputBytes) + { + throw Malformed("input is too large."); + } + + return Guard(() => DecodeImage(data, limits)); + } + + /// Converts a decoded image to a . + /// The decoded image. + /// A new surface. + private static Surface ToSurface(Jpeg2000Image image) + { + var surface = new Surface(image.Width, image.Height); + try + { + var n = image.ColorChannelCount; + var src = image.ColorSamples; + for (var y = 0; y < image.Height; y++) + { + var row = surface.GetRowSpan(y); + for (var x = 0; x < image.Width; x++) + { + var p = ((y * image.Width) + x) * n; + var a = image.AlphaSamples is null ? (byte)255 : image.AlphaSamples[(y * image.Width) + x]; + row[x] = ToPixel(src, p, n, a, image.AlphaPremultiplied); + } + } + + return surface; + } + catch + { + surface.Dispose(); + throw; + } + } + + private static Rgba32 ToPixel(byte[] src, int p, int channels, byte alpha, bool premultiplied) + { + byte r, g, b; + switch (channels) + { + case 1: + r = g = b = src[p]; + break; + case 4: + { + // Four channels are only ever CMYK here: ResolveLayout rejects every other 4-channel space. + // Naive device CMYK to RGB conversion. + var k = src[p + 3]; + r = (byte)(255 - Math.Min(255, src[p] + k)); + g = (byte)(255 - Math.Min(255, src[p + 1] + k)); + b = (byte)(255 - Math.Min(255, src[p + 2] + k)); + break; + } + + default: + r = src[p]; + g = src[p + 1]; + b = src[p + 2]; + break; + } + + if (premultiplied && alpha is > 0 and < 255) + { + r = (byte)Math.Min(255, ((r * 255) + (alpha / 2)) / alpha); + g = (byte)Math.Min(255, ((g * 255) + (alpha / 2)) / alpha); + b = (byte)Math.Min(255, ((b * 255) + (alpha / 2)) / alpha); + } + + return new Rgba32(r, g, b, alpha); + } + + /// Reads a stream fully, rejecting inputs above . + /// The stream to read. + /// The resource limits. + /// The bytes read. + private static byte[] ReadAllBytes(Stream stream, Jpeg2000DecoderLimits limits) + { + using var memory = new MemoryStream(); + var buffer = new byte[81920]; + int read; + while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + { + if (memory.Length + read > limits.MaxInputBytes) + { + throw Malformed("input is too large."); + } + + memory.Write(buffer, 0, read); + } + + return memory.ToArray(); + } + + /// + /// A last-resort backstop for the decoding steps: converts an index or arithmetic fault into + /// so that only the documented exception types escape on bad input. + /// + /// The result type. + /// The decoding step. + /// The step's result. + /// + /// This is deliberately narrow. Every structural field is validated explicitly where it is parsed + /// (marker lengths, indices, counts, exponents, box sizes), so well-understood malformed input fails with + /// a specific message and no inner exception. Only and + /// (raised by size arithmetic) are mapped; the + /// original is kept as the inner exception, which the tests assert to be absent for named malformed + /// cases. Null references, invalid casts, argument faults and out-of-memory conditions are never + /// mapped: they indicate decoder defects and propagate. Memory is bounded up front by + /// instead of by catching . + /// + private static T Guard(Func action) + { + try + { + return action(); + } + catch (Exception ex) when (ex is IndexOutOfRangeException or OverflowException) + { + throw new InvalidDataException("Invalid JPEG 2000 data: " + ex.Message, ex); + } + } +} diff --git a/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000ColorSpace.cs b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000ColorSpace.cs new file mode 100644 index 00000000..ade71744 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000ColorSpace.cs @@ -0,0 +1,19 @@ +namespace DemaConsulting.CanvasNet.Codecs; + +/// +/// The color space of the color channels of a decoded . +/// +public enum Jpeg2000ColorSpace +{ + /// The color space is not one of the well-known ones; the channel count describes the layout. + Unknown = 0, + + /// A single gray channel. + Gray = 1, + + /// Three channels in red, green, blue order (sYCC data is converted to this). + Srgb = 2, + + /// Four channels in cyan, magenta, yellow, black order. + Cmyk = 3, +} diff --git a/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000DecoderLimits.cs b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000DecoderLimits.cs new file mode 100644 index 00000000..bbb939cb --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000DecoderLimits.cs @@ -0,0 +1,109 @@ +using DemaConsulting.CanvasNet.Canvas; + +namespace DemaConsulting.CanvasNet.Codecs; + +/// +/// The resource limits enforced by while decoding untrusted JPEG 2000 data. +/// +/// +/// +/// The limits are checked while the headers are parsed, before any plane, tile, precinct or +/// code-block structure is allocated, so a header of a few hundred bytes cannot force an +/// allocation larger than the limits allow. Exceeding any limit raises +/// . +/// +/// +/// Independently of these absolute limits, the decoder also bounds its structures by the amount +/// of data actually present: a tile cannot declare more packets than its tile-part data has +/// bits (every packet has at least a one-bit header), and the progression-iteration work is +/// bounded by a small constant plus a multiple of the input length. +/// +/// +/// The defaults follow . Callers that know more about the +/// expected image (for example a PDF image dictionary with its own /Width and +/// /Height) can pass tighter limits to +/// . +/// +/// +public sealed record Jpeg2000DecoderLimits +{ + /// Gets the default limits used by and . + public static Jpeg2000DecoderLimits Default { get; } = new(); + + /// Gets the maximum number of input bytes accepted. The default is 256 MiB. + public long MaxInputBytes { get; init; } = 256L * 1024 * 1024; + + /// Gets the maximum image width in pixels. The default is . + public int MaxWidth { get; init; } = Surface.MaxDimension; + + /// Gets the maximum image height in pixels. The default is . + public int MaxHeight { get; init; } = Surface.MaxDimension; + + /// + /// Gets the maximum number of samples (width times height times channels) of the decoded image and of + /// the component planes held while decoding. The default is 2^27, i.e. 256 MiB of component planes. + /// + public long MaxTotalSamples { get; init; } = 1L << 27; + + /// Gets the maximum number of component samples of a single tile. The default is 2^26. + public long MaxTileSamples { get; init; } = 1L << 26; + + /// Gets the maximum number of tiles. The default is 65535, the largest number the format can address. + public int MaxTiles { get; init; } = 65535; + + /// Gets the maximum number of precincts of a single tile. The default is 2^18. + public long MaxTilePrecincts { get; init; } = 1L << 18; + + /// Gets the maximum number of code-blocks of a single tile. The default is 2^20. + public long MaxTileCodeBlocks { get; init; } = 1L << 20; + + /// Gets the maximum number of packets (precincts times layers) of a single tile. The default is 2^22. + public long MaxTilePackets { get; init; } = 1L << 22; + + /// + /// Gets the maximum number of progression-order entries (POC) in effect for the main header or for a + /// tile. The default is 128. + /// + public int MaxProgressionChanges { get; init; } = 128; + + /// + /// Gets the absolute ceiling of progression-iteration steps (candidate packets plus position steps) + /// for one decode, cumulative over all tiles and progression changes. The default is 2^30. The + /// effective ceiling is lower for small inputs because it also scales with the input length. + /// + public long MaxProgressionSteps { get; init; } = 1L << 30; + + /// + /// Gets the absolute ceiling of entropy-decoding work (sample-passes) for one decode. The default is + /// 2^34 (at least the largest default image, samples at 88 passes, so + /// a valid image never reaches it). The effective ceiling is lower for small inputs because it also + /// scales with the input length, which is what bounds hostile streams. + /// + public long MaxTier1Work { get; init; } = 1L << 34; + + /// Checks that every limit is positive and within the range the decoder can honor. + /// Thrown when a limit is not positive or is too large. + internal void Validate() + { + Check(MaxInputBytes, int.MaxValue, nameof(MaxInputBytes)); + Check(MaxWidth, int.MaxValue, nameof(MaxWidth)); + Check(MaxHeight, int.MaxValue, nameof(MaxHeight)); + Check(MaxTotalSamples, int.MaxValue, nameof(MaxTotalSamples)); + Check(MaxTileSamples, int.MaxValue, nameof(MaxTileSamples)); + Check(MaxTiles, 65535, nameof(MaxTiles)); + Check(MaxTilePrecincts, int.MaxValue, nameof(MaxTilePrecincts)); + Check(MaxTileCodeBlocks, int.MaxValue, nameof(MaxTileCodeBlocks)); + Check(MaxTilePackets, int.MaxValue, nameof(MaxTilePackets)); + Check(MaxProgressionChanges, 65535, nameof(MaxProgressionChanges)); + Check(MaxProgressionSteps, long.MaxValue / 2, nameof(MaxProgressionSteps)); + Check(MaxTier1Work, long.MaxValue / 2, nameof(MaxTier1Work)); + } + + private static void Check(long value, long max, string name) + { + if (value < 1 || value > max) + { + throw new ArgumentOutOfRangeException(name, value, "The limit must be between 1 and " + max + "."); + } + } +} diff --git a/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000Image.cs b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000Image.cs new file mode 100644 index 00000000..0ef5f5d3 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Codecs/Jpeg2000Image.cs @@ -0,0 +1,43 @@ +namespace DemaConsulting.CanvasNet.Codecs; + +/// +/// A decoded JPEG 2000 image: 8-bit interleaved color samples plus an optional separate alpha plane. +/// +/// The image width in pixels. +/// The image height in pixels. +/// The color space of the color channels. +/// The number of color channels (1, 3 or 4). +/// +/// The color samples scaled to 8 bits, interleaved per pixel, row by row, with +/// bytes per pixel. +/// +/// +/// The opacity samples scaled to 8 bits, one byte per pixel, or when the image has no alpha channel. +/// +/// Whether the color samples are premultiplied by the alpha samples. +/// The embedded ICC profile, if the file carried one. The profile is reported but never applied. +public sealed record Jpeg2000Image( + int Width, + int Height, + Jpeg2000ColorSpace ColorSpace, + int ColorChannelCount, + byte[] ColorSamples, + byte[]? AlphaSamples, + bool AlphaPremultiplied, + byte[]? IccProfile) +{ + /// Gets a value indicating whether the image carries an alpha channel. + public bool HasAlpha => AlphaSamples is not null; + + /// + /// Gets the bit depth of the first color channel's source component before scaling to 8 bits + /// (for a palette-mapped image, the depth of the palette index component). + /// + public int BitDepth { get; init; } = 8; + + /// + /// Gets a value indicating whether the JP2 file's palette (pclr/cmap) was applied, so + /// hold palette colors rather than raw component samples. + /// + public bool HasPalette { get; init; } +} diff --git a/src/DemaConsulting.CanvasNet/Codecs/NamespaceDoc.cs b/src/DemaConsulting.CanvasNet/Codecs/NamespaceDoc.cs index e967e14f..1199fb14 100644 --- a/src/DemaConsulting.CanvasNet/Codecs/NamespaceDoc.cs +++ b/src/DemaConsulting.CanvasNet/Codecs/NamespaceDoc.cs @@ -7,16 +7,19 @@ namespace DemaConsulting.CanvasNet.Codecs; /// data in common image file formats: uncompressed Windows BMP (), a /// restricted, dependency-free subset of PNG (), a restricted, /// dependency-free subset of TIFF (), a restricted, -/// dependency-free subset of baseline JPEG (), and a restricted, -/// dependency-free, decode-only, first-frame-only subset of GIF (). +/// dependency-free subset of baseline JPEG (), a restricted, +/// dependency-free, decode-only, first-frame-only subset of GIF (), and a +/// dependency-free, decode-only JPEG 2000 Part 1 decoder (). /// Four raster codecs (, , /// , and ) each convert to and from a /// pixel buffer using only its /// existing public API - no format-specific members are added to /// itself, so new codecs can be added -/// independently in the future. Unlike those four, is decode-only - it -/// loads a from the first frame of a -/// GIF file but has no Save method. SVG rasterization is no longer part of this +/// independently in the future. Unlike those four, and +/// are decode-only: loads a +/// from the first frame of a +/// GIF file and from a JPEG 2000 image, and neither has a Save +/// method. SVG rasterization is no longer part of this /// namespace - it now lives in the separate DemaConsulting.CanvasNet.Svg package's /// DemaConsulting.CanvasNet.Svg namespace. /// diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/DemaConsulting.CanvasNet.Pdf.Tests.csproj b/test/DemaConsulting.CanvasNet.Pdf.Tests/DemaConsulting.CanvasNet.Pdf.Tests.csproj index f94af7c2..75494103 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/DemaConsulting.CanvasNet.Pdf.Tests.csproj +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/DemaConsulting.CanvasNet.Pdf.Tests.csproj @@ -75,6 +75,12 @@ + + + + + @@ -92,4 +98,22 @@ + + + + PreserveNewest + + + + + + + PreserveNewest + + + diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentJpxTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentJpxTests.cs new file mode 100644 index 00000000..c2bd975d --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentJpxTests.cs @@ -0,0 +1,707 @@ +// cspell:ignore JPXDecode SMaskInData cdef +using System.Text; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Codecs; +using DemaConsulting.CanvasNet.Tests.Codecs; + +namespace DemaConsulting.CanvasNet.Pdf.Tests; + +/// +/// Tests for /Filter /JPXDecode (JPEG 2000) image XObjects and the /SMask +/// soft-mask path in . The JPEG 2000 streams are produced by the +/// test-only Jpeg2000TestEncoder (linked from the core test project). +/// +public class PdfDocumentJpxTests +{ + private static readonly PdfRenderOptions Transparent = new() { BackgroundColor = new(0, 0, 0, 0) }; + + private static J2kOptions Lossless(int levels = 1) => new() { Levels = levels }; + + /// Builds an image whose component c is filled with values[c]. + private static J2kImage Flat(int width, int height, params int[] values) + { + var image = Jpeg2000TestEncoder.MakeImage(width, height, values.Length); + for (var c = 0; c < values.Length; c++) + { + Array.Fill(image.Components[c].Samples, values[c]); + } + + return image; + } + + private static byte[] Jp2(J2kImage image, J2kJp2Options? options = null, int levels = 1) => + Jpeg2000TestEncoder.WrapJp2(image, Jpeg2000TestEncoder.Encode(image, Lossless(levels)), options ?? new J2kJp2Options()); + + private static byte[] StreamObject(string entries, byte[] data) + { + var header = Encoding.ASCII.GetBytes($"<< {entries} /Length {data.Length} >>\nstream\n"); + return [.. header, .. data, .. "\nendstream"u8.ToArray()]; + } + + private static byte[] ImageObject(string entries, byte[] jpx) => + StreamObject($"/Type /XObject /Subtype /Image /Width 8 /Height 8 /BitsPerComponent 8 /Filter /JPXDecode {entries}", jpx); + + /// Builds a one-page 100x100 PDF placing /Im0 (object 5) over the whole page. + private static byte[] BuildPdf(params byte[][] extraObjects) => + BuildPdf("100 0 0 100 0 0 cm /Im0 Do", extraObjects); + + /// Builds a one-page 100x100 PDF with the given page content stream and extra objects from object 5. + private static byte[] BuildPdf(string contentText, byte[][] extraObjects) + { + var content = Encoding.ASCII.GetBytes(contentText); + var bodies = new List + { + "<< /Type /Catalog /Pages 2 0 R >>"u8.ToArray(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>"u8.ToArray(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>"u8.ToArray(), + StreamObject(string.Empty, content), + }; + bodies.AddRange(extraObjects); + + var buffer = new List(Encoding.ASCII.GetBytes("%PDF-1.7\n")); + var offsets = new List(); + for (var i = 0; i < bodies.Count; i++) + { + offsets.Add(buffer.Count); + buffer.AddRange(Encoding.ASCII.GetBytes($"{i + 1} 0 obj\n")); + buffer.AddRange(bodies[i]); + buffer.AddRange(Encoding.ASCII.GetBytes("\nendobj\n")); + } + + var xref = buffer.Count; + buffer.AddRange(Encoding.ASCII.GetBytes($"xref\n0 {bodies.Count + 1}\n0000000000 65535 f \n")); + foreach (var offset in offsets) + { + buffer.AddRange(Encoding.ASCII.GetBytes($"{offset:D10} 00000 n \n")); + } + + buffer.AddRange(Encoding.ASCII.GetBytes( + $"trailer\n<< /Size {bodies.Count + 1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n")); + return [.. buffer]; + } + + private static Surface Render(params byte[][] extraObjects) + { + using var document = PdfDocument.Open(new MemoryStream(BuildPdf(extraObjects))); + return document.Render(0, 100, 100, Transparent); + } + + private static void AssertNear(Rgba32 expected, Rgba32 actual, int tolerance = 2) + { + Assert.True(Math.Abs(expected.R - actual.R) <= tolerance, $"R expected {expected.R} actual {actual.R}"); + Assert.True(Math.Abs(expected.G - actual.G) <= tolerance, $"G expected {expected.G} actual {actual.G}"); + Assert.True(Math.Abs(expected.B - actual.B) <= tolerance, $"B expected {expected.B} actual {actual.B}"); + Assert.True(Math.Abs(expected.A - actual.A) <= tolerance, $"A expected {expected.A} actual {actual.A}"); + } + + /// + /// Proves a JPX image produced by a real encoder (ImageMagick/OpenJPEG, not the test-only + /// encoder) embedded as /JPXDecode renders close to the source pixels when placed 1:1 on the page. + /// + [Fact] + public void PdfDocument_Images_Jpx_RealEncoderFixture_RendersCloseToSourcePixels() + { + // Arrange: a 100x70 real-encoder gray JP2 placed 1:1 at the top of a 100x100 page (PDF y 30..100) + var directory = Path.Join(AppContext.BaseDirectory, "Jpeg2000Fixtures"); + var jpx = File.ReadAllBytes(Path.Join(directory, "gray.jp2")); + using var source = PngCodec.Load(Path.Join(directory, "source_gray.png")); + var pdf = BuildPdf("100 0 0 70 0 30 cm /Im0 Do", [ImageObject(string.Empty, jpx)]); + + // Act + using var document = PdfDocument.Open(new MemoryStream(pdf)); + using var surface = document.Render(0, 100, 100, Transparent); + + // Assert: the image fills the top 70 pixel rows (PDF y 30..100). The renderer resamples + // the (noisy) image, so compare the mean absolute difference rather than exact pixels + double total = 0; + var count = 0; + for (var y = 1; y < 69; y++) + { + for (var x = 1; x < 99; x++) + { + total += Math.Abs(source[x, y].R - surface[x, y].R); + count++; + } + } + + Assert.True(total / count < 8, $"mean absolute difference {total / count:F2} too large"); + + // The area below the image stays transparent + Assert.Equal(0, surface[50, 90].A); + } + + /// Proves a JPX image without /ColorSpace uses the JP2's own gray color space. + [Fact] + public void PdfDocument_Images_Jpx_GrayWithoutColorSpace_UsesJp2ColorSpace() + { + var jpx = Jp2(Flat(8, 8, 100), new J2kJp2Options { EnumCs = 17 }); + using var surface = Render(ImageObject(string.Empty, jpx)); + AssertNear(new Rgba32(100, 100, 100, 255), surface[50, 50], 0); + } + + /// Proves a JPX image without /ColorSpace uses the JP2's own sRGB color space. + [Fact] + public void PdfDocument_Images_Jpx_RgbWithoutColorSpace_UsesJp2ColorSpace() + { + var jpx = Jp2(Flat(8, 8, 200, 50, 10), new J2kJp2Options { EnumCs = 16 }, levels: 2); + using var surface = Render(ImageObject(string.Empty, jpx)); + AssertNear(new Rgba32(200, 50, 10, 255), surface[50, 50], 0); + } + + /// Proves a JPX CMYK image decodes through the naive CMYK conversion. + [Fact] + public void PdfDocument_Images_Jpx_CmykWithoutColorSpace_UsesJp2ColorSpace() + { + var jpx = Jp2(Flat(8, 8, 255, 0, 0, 0), new J2kJp2Options { EnumCs = 12 }); + using var surface = Render(ImageObject(string.Empty, jpx)); + AssertNear(new Rgba32(0, 255, 255, 255), surface[50, 50], 0); + } + + /// Proves an explicit /DeviceRGB color space is honoured when it matches the data. + [Fact] + public void PdfDocument_Images_Jpx_ExplicitDeviceRgb_HonorsColorSpace() + { + var jpx = Jp2(Flat(8, 8, 10, 20, 30), new J2kJp2Options { EnumCs = 16 }); + using var surface = Render(ImageObject("/ColorSpace /DeviceRGB", jpx)); + AssertNear(new Rgba32(10, 20, 30, 255), surface[50, 50], 0); + } + + /// Proves an explicit /DeviceCMYK color space is honoured for four-channel data. + [Fact] + public void PdfDocument_Images_Jpx_ExplicitDeviceCmyk_HonorsColorSpace() + { + var jpx = Jp2(Flat(8, 8, 0, 255, 0, 0), new J2kJp2Options { EnumCs = 12 }); + using var surface = Render(ImageObject("/ColorSpace /DeviceCMYK", jpx)); + AssertNear(new Rgba32(255, 0, 255, 255), surface[50, 50], 0); + } + + /// Proves /ICCBased is resolved by component count. + [Fact] + public void PdfDocument_Images_Jpx_IccBasedColorSpace_ResolvesByComponentCount() + { + var jpx = Jp2(Flat(8, 8, 90, 80, 70), new J2kJp2Options { EnumCs = 16 }); + var icc = StreamObject("/N 3 /Alternate /DeviceRGB", [0, 1, 2, 3]); + using var surface = Render(ImageObject("/ColorSpace [/ICCBased 6 0 R]", jpx), icc); + AssertNear(new Rgba32(90, 80, 70, 255), surface[50, 50], 0); + } + + /// Proves an /Indexed color space overrides the JP2 color space: samples are palette indices. + [Fact] + public void PdfDocument_Images_Jpx_IndexedColorSpace_OverridesJp2ColorSpace() + { + var jpx = Jp2(Flat(8, 8, 1), new J2kJp2Options { EnumCs = 17 }); + using var surface = Render(ImageObject("/ColorSpace [/Indexed /DeviceRGB 1 ]", jpx)); + AssertNear(new Rgba32(0, 255, 0, 255), surface[50, 50], 0); + } + + /// Proves an /Indexed color space over non-8-bit JPX samples fails closed (samples are scaled to 8 bits). + [Fact] + public void PdfDocument_Images_Jpx_IndexedColorSpaceNonEightBit_ThrowsUnsupportedImageFeatureException() + { + var jpx = Jp2(Jpeg2000TestEncoder.MakeImage(8, 8, 1, 4, 1, 0), new J2kJp2Options { EnumCs = 17 }); + Assert.Throws( + () => Render(ImageObject("/ColorSpace [/Indexed /DeviceRGB 1 ]", jpx))); + } + + /// Proves a PDF /ColorSpace override on JP2 data that carries its own palette fails closed. + [Fact] + public void PdfDocument_Images_Jpx_ColorSpaceOverJp2Palette_ThrowsUnsupportedImageFeatureException() + { + var image = Flat(8, 8, 1); + int[][] palette = [[255, 0, 0], [0, 255, 0]]; + var jpx = Jp2(image, new J2kJp2Options { Palette = palette, PaletteDepths = [8, 8, 8], Cmap = [(0, 1, 0), (0, 1, 1), (0, 1, 2)] }); + Assert.Throws(() => Render(ImageObject("/ColorSpace /DeviceRGB", jpx))); + + // Without an override the JP2's own palette is honored + using var surface = Render(ImageObject(string.Empty, jpx)); + AssertNear(new Rgba32(0, 255, 0, 255), surface[50, 50], 0); + } + + /// Proves /Decode [1 0] inverts a gray JPX image. + [Fact] + public void PdfDocument_Images_Jpx_DecodeArray_InvertsSamples() + { + var jpx = Jp2(Flat(8, 8, 50), new J2kJp2Options { EnumCs = 17 }); + using var surface = Render(ImageObject("/Decode [1 0]", jpx)); + AssertNear(new Rgba32(205, 205, 205, 255), surface[50, 50], 0); + } + + /// Proves /SMaskInData 0 (the default) ignores the codestream's opacity channel. + [Fact] + public void PdfDocument_Images_Jpx_SMaskInDataZero_IgnoresAlphaChannel() + { + var jpx = AlphaJp2(200, 50, 10, 128, type: 1); + using var surface = Render(ImageObject("/ColorSpace /DeviceRGB /SMaskInData 0", jpx)); + AssertNear(new Rgba32(200, 50, 10, 255), surface[50, 50], 0); + } + + /// Proves /SMaskInData 1 applies the opacity channel as straight alpha. + [Fact] + public void PdfDocument_Images_Jpx_SMaskInDataOne_AppliesAlphaChannel() + { + var jpx = AlphaJp2(200, 50, 10, 128, type: 1); + using var surface = Render(ImageObject("/ColorSpace /DeviceRGB /SMaskInData 1", jpx)); + var pixel = surface[50, 50]; + Assert.Equal(128, pixel.A); + AssertNear(new Rgba32(200, 50, 10, 128), pixel, 3); + } + + /// Proves /SMaskInData 2 un-premultiplies the color samples. + [Fact] + public void PdfDocument_Images_Jpx_SMaskInDataTwo_UnpremultipliesColor() + { + var jpx = AlphaJp2(100, 25, 5, 128, type: 2); + using var surface = Render(ImageObject("/ColorSpace /DeviceRGB /SMaskInData 2", jpx)); + var pixel = surface[50, 50]; + Assert.Equal(128, pixel.A); + AssertNear(new Rgba32(199, 50, 10, 128), pixel, 4); + } + + /// Proves a JPX /SMask image (of different dimensions) supplies the alpha channel. + [Fact] + public void PdfDocument_Images_Jpx_JpxSMask_AppliesLuminanceAsAlpha() + { + var baseJpx = Jp2(Flat(8, 8, 200, 50, 10), new J2kJp2Options { EnumCs = 16 }); + var mask = Flat(2, 1, 0); + mask.Components[0].Samples[1] = 255; + var maskJpx = Jp2(mask, new J2kJp2Options { EnumCs = 17 }, levels: 0); + var maskObject = StreamObject( + "/Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /JPXDecode", + maskJpx); + using var surface = Render(ImageObject("/SMask 6 0 R", baseJpx), maskObject); + Assert.Equal(0, surface[25, 50].A); + AssertNear(new Rgba32(200, 50, 10, 255), surface[75, 50], 0); + } + + /// Proves a JPX /SMask honours its own /Decode inversion. + [Fact] + public void PdfDocument_Images_Jpx_JpxSMaskWithDecode_InvertsMask() + { + var baseJpx = Jp2(Flat(8, 8, 200, 50, 10), new J2kJp2Options { EnumCs = 16 }); + var maskJpx = Jp2(Flat(8, 8, 255), new J2kJp2Options { EnumCs = 17 }); + var maskObject = StreamObject( + "/Type /XObject /Subtype /Image /Width 8 /Height 8 /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /JPXDecode /Decode [1 0]", + maskJpx); + using var surface = Render(ImageObject("/SMask 6 0 R", baseJpx), maskObject); + Assert.Equal(0, surface[50, 50].A); + } + + /// Proves an /SMask on a non-JPX (raw sample) image is applied too. + [Fact] + public void PdfDocument_Images_SMaskOnRawImage_AppliesLuminanceAsAlpha() + { + var image = StreamObject( + "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 6 0 R", + [255]); + var mask = StreamObject( + "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", + [64]); + using var surface = Render(image, mask); + Assert.Equal(64, surface[50, 50].A); + } + + /// Proves an explicit /SMask takes precedence over /SMaskInData. + [Fact] + public void PdfDocument_Images_Jpx_SMaskAndSMaskInData_SMaskWins() + { + var jpx = AlphaJp2(200, 50, 10, 128, type: 1); + var mask = StreamObject( + "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", + [255]); + using var surface = Render(ImageObject("/ColorSpace /DeviceRGB /SMaskInData 1 /SMask 6 0 R", jpx), mask); + Assert.Equal(255, surface[50, 50].A); + } + + /// Proves a /ColorSpace whose component count disagrees with the data fails closed. + [Fact] + public void PdfDocument_Images_Jpx_ColorSpaceComponentMismatch_ThrowsInvalidDataException() + { + var jpx = Jp2(Flat(8, 8, 1, 2, 3), new J2kJp2Options { EnumCs = 16 }); + Assert.Throws(() => Render(ImageObject("/ColorSpace /DeviceGray", jpx))); + } + + /// Proves an out-of-range /SMaskInData fails closed. + [Fact] + public void PdfDocument_Images_Jpx_InvalidSMaskInData_ThrowsInvalidDataException() + { + var jpx = Jp2(Flat(8, 8, 1), new J2kJp2Options { EnumCs = 17 }); + Assert.Throws(() => Render(ImageObject("/SMaskInData 3", jpx))); + } + + /// Proves a malformed /Decode array fails closed. + [Fact] + public void PdfDocument_Images_Jpx_DecodeArrayWrongLength_ThrowsInvalidDataException() + { + var jpx = Jp2(Flat(8, 8, 1), new J2kJp2Options { EnumCs = 17 }); + Assert.Throws(() => Render(ImageObject("/Decode [0 1 0 1]", jpx))); + } + + /// Proves garbage JPX data fails closed. + [Fact] + public void PdfDocument_Images_Jpx_GarbageData_ThrowsInvalidDataException() + { + Assert.Throws(() => Render(ImageObject(string.Empty, [1, 2, 3, 4, 5, 6, 7, 8]))); + } + + /// Proves a truncated JPX stream fails closed with . + [Fact] + public void PdfDocument_Images_Jpx_TruncatedData_ThrowsInvalidDataException() + { + var jpx = Jp2(Flat(8, 8, 200, 50, 10), new J2kJp2Options { EnumCs = 16 }); + var truncated = jpx[..(jpx.Length / 2)]; + Assert.Throws(() => Render(ImageObject(string.Empty, truncated))); + } + + /// Proves a codestream header declaring huge dimensions is rejected without decoding. + [Fact] + public void PdfDocument_Images_Jpx_OversizedHeader_ThrowsInvalidDataException() + { + var jpx = Jp2(Flat(8, 8, 1), new J2kJp2Options { EnumCs = 17 }); + var siz = jpx.AsSpan().IndexOf(new byte[] { 0xFF, 0x51 }); + Assert.True(siz > 0); + + // Xsiz and Ysiz follow the marker, Lsiz and Rsiz (big-endian): set both to 100000 + foreach (var offset in new[] { siz + 6, siz + 10 }) + { + jpx[offset] = 0; + jpx[offset + 1] = 1; + jpx[offset + 2] = 0x86; + jpx[offset + 3] = 0xA0; + } + + Assert.Throws(() => Render(ImageObject(string.Empty, jpx))); + } + + /// Proves a /Pattern /ColorSpace on a JPX image is rejected. + [Fact] + public void PdfDocument_Images_Jpx_PatternColorSpace_ThrowsInvalidDataException() + { + var jpx = Jp2(Flat(8, 8, 1), new J2kJp2Options { EnumCs = 17 }); + Assert.Throws(() => Render(ImageObject("/ColorSpace /Pattern", jpx))); + } + + /// Proves /SMaskInData 2 un-premultiplies CMYK component samples before the color conversion. + [Fact] + public void PdfDocument_Images_Jpx_SMaskInDataTwoCmyk_UnpremultipliesBeforeConversion() + { + // Premultiplied C = 128 at alpha 128 is a full-strength cyan: naive CMYK->RGB gives (0, 255, 255) + var jpx = Jp2( + Flat(8, 8, 128, 0, 0, 0, 128), + new J2kJp2Options { EnumCs = 12, Cdef = [(0, 0, 1), (1, 0, 2), (2, 0, 3), (3, 0, 4), (4, 1, 0)] }); + using var surface = Render(ImageObject("/ColorSpace /DeviceCMYK /SMaskInData 2", jpx)); + var pixel = surface[50, 50]; + Assert.Equal(128, pixel.A); + AssertNear(new Rgba32(0, 255, 255, 128), pixel, 1); + } + + /// Proves /SMaskInData 2 over an /Indexed color space fails closed. + [Fact] + public void PdfDocument_Images_Jpx_SMaskInDataTwoIndexed_ThrowsUnsupportedImageFeatureException() + { + var jpx = Jp2(Flat(8, 8, 1, 128), new J2kJp2Options { EnumCs = 17, Cdef = [(0, 0, 1), (1, 1, 0)] }); + Assert.Throws( + () => Render(ImageObject("/ColorSpace [/Indexed /DeviceRGB 1 ] /SMaskInData 2", jpx))); + } + + /// Proves a non-stream /SMask fails closed. + [Fact] + public void PdfDocument_Images_SMaskNotAStream_ThrowsInvalidDataException() + { + var image = RawGrayImage("/SMask 6 0 R", [255]); + Assert.Throws(() => Render(image, "<< /Type /XObject >>"u8.ToArray())); + } + + /// Proves an /SMask stream lacking /Subtype /Image fails closed. + [Fact] + public void PdfDocument_Images_SMaskNotImageSubtype_ThrowsInvalidDataException() + { + var image = RawGrayImage("/SMask 6 0 R", [255]); + var mask = StreamObject( + "/Type /XObject /Subtype /Form /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", + [64]); + Assert.Throws(() => Render(image, mask)); + } + + /// Proves a non-gray /SMask image fails closed. + [Fact] + public void PdfDocument_Images_SMaskNotGray_ThrowsInvalidDataException() + { + var image = RawGrayImage("/SMask 6 0 R", [255]); + var mask = StreamObject( + "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8", + [64, 64, 64]); + Assert.Throws(() => Render(image, mask)); + } + + /// Proves a raw soft-mask image honours its own /Decode inversion. + [Fact] + public void PdfDocument_Images_SMaskRawWithDecode_InvertsMask() + { + var image = RawGrayImage("/SMask 6 0 R", [255]); + var mask = StreamObject( + "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Decode [1 0]", + [64]); + using var surface = Render(image, mask); + Assert.Equal(191, surface[50, 50].A); + } + + /// Proves /Decode [1 0] inverts a raw gray image (the shared sample path). + [Fact] + public void PdfDocument_Images_RawWithDecode_InvertsSamples() + { + using var surface = Render(RawGrayImage("/Decode [1 0]", [55])); + AssertNear(new Rgba32(200, 200, 200, 255), surface[50, 50], 0); + } + + /// Proves a malformed /Decode array on a raw image fails closed. + [Fact] + public void PdfDocument_Images_RawWithWrongLengthDecode_ThrowsInvalidDataException() + { + Assert.Throws(() => Render(RawGrayImage("/Decode [1 0 1 0]", [55]))); + } + + /// Proves an /SMask is applied to a DCTDecode image. + [Fact] + public void PdfDocument_Images_SMaskOnDctImage_AppliesLuminanceAsAlpha() + { + using var source = new Surface(8, 8); + source.Clear(new Rgba32(200, 200, 200, 255)); + using var jpegStream = new MemoryStream(); + JpegCodec.Save(source, jpegStream, quality: 100); + var image = StreamObject( + "/Type /XObject /Subtype /Image /Width 8 /Height 8 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /SMask 6 0 R", + jpegStream.ToArray()); + var mask = StreamObject( + "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", + [128]); + using var surface = Render(image, mask); + Assert.Equal(128, surface[50, 50].A); + } + + /// Proves a non-identity /Decode is applied exactly to a 3-channel DCTDecode image. + [Fact] + public void PdfDocument_Images_DctRgbWithDecode_InvertsSamples() + { + using var source = new Surface(8, 8); + source.Clear(new Rgba32(200, 100, 50, 255)); + using var jpegStream = new MemoryStream(); + JpegCodec.Save(source, jpegStream, quality: 100); + var image = StreamObject( + "/Type /XObject /Subtype /Image /Width 8 /Height 8 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Decode [1 0 1 0 1 0]", + jpegStream.ToArray()); + using var inverted = Render(image); + var plain = StreamObject( + "/Type /XObject /Subtype /Image /Width 8 /Height 8 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode", + jpegStream.ToArray()); + using var reference = Render(plain); + var expected = reference[50, 50]; + AssertNear(new Rgba32((byte)(255 - expected.R), (byte)(255 - expected.G), (byte)(255 - expected.B), 255), inverted[50, 50], 1); + AssertNear(new Rgba32(55, 155, 205, 255), inverted[50, 50], 3); + } + + /// Proves a non-identity /Decode is applied exactly to a 1-channel (gray) DCTDecode image. + [Fact] + public void PdfDocument_Images_DctGrayWithDecode_InvertsSamples() + { + var jpeg = File.ReadAllBytes(Path.Join(AppContext.BaseDirectory, "JpegFixtures", "grayscale_baseline.jpg")); + var info = JpegCodec.GetInfo(new MemoryStream(jpeg)); + Assert.Equal(1, info.Channels); + var dictionary = $"/Type /XObject /Subtype /Image /Width {info.Width} /Height {info.Height} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /DCTDecode"; + using var reference = Render(StreamObject(dictionary, jpeg)); + using var inverted = Render(StreamObject(dictionary + " /Decode [1 0]", jpeg)); + foreach (var (x, y) in new[] { (10, 10), (50, 50), (90, 30) }) + { + AssertNear(new Rgba32((byte)(255 - reference[x, y].R), (byte)(255 - reference[x, y].G), (byte)(255 - reference[x, y].B), 255), inverted[x, y], 1); + } + } + + /// Proves a 4-component (CMYK) JPEG with a non-identity /Decode still fails closed (the JPEG codec rejects it). + [Fact] + public void PdfDocument_Images_DctFourChannelWithDecode_ThrowsInvalidDataException() + { + // SOI, SOF0 (8-bit, 8x8, 4 components), EOI: rejected before any pixel decoding. + byte[] jpeg = + [ + 0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x14, 0x08, 0x00, 0x08, 0x00, 0x08, 0x04, + 0x01, 0x11, 0x00, 0x02, 0x11, 0x00, 0x03, 0x11, 0x00, 0x04, 0x11, 0x00, + 0xFF, 0xD9, + ]; + var image = StreamObject( + "/Type /XObject /Subtype /Image /Width 8 /Height 8 /ColorSpace /DeviceCMYK /BitsPerComponent 8 /Filter /DCTDecode /Decode [1 0 1 0 1 0 1 0]", + jpeg); + Assert.Throws(() => Render(image)); + } + + /// Proves a 1-bit /SMask supplies a fully transparent and a fully opaque pixel. + [Fact] + public void PdfDocument_Images_SMaskOneBit_AppliesBinaryAlpha() + { + var image = StreamObject( + "/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 6 0 R", + [255, 255]); + var mask = StreamObject( + "/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 1", + [0b0100_0000]); + using var surface = Render(image, mask); + Assert.Equal(0, surface[25, 50].A); + Assert.Equal(255, surface[75, 50].A); + } + + /// Proves a 16-bit /SMask is applied through its high byte. + [Fact] + public void PdfDocument_Images_SMaskSixteenBit_AppliesHighByteAsAlpha() + { + var image = RawGrayImage("/SMask 6 0 R", [255]); + var mask = StreamObject( + "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 16", + [0x80, 0x00]); + using var surface = Render(image, mask); + Assert.Equal(128, surface[50, 50].A); + } + + /// Proves a 4-bit /SMask honours /Decode and row padding. + [Fact] + public void PdfDocument_Images_SMaskFourBitWithDecode_InvertsMask() + { + var image = RawGrayImage("/SMask 6 0 R", [255]); + var mask = StreamObject( + "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 4 /Decode [1 0]", + [0x30]); + using var surface = Render(image, mask); + Assert.Equal(204, surface[50, 50].A); + } + + /// Proves a 1-bit base image is unpacked MSB first and scaled to full range. + [Fact] + public void PdfDocument_Images_RawOneBitGray_UnpacksSamples() + { + var image = StreamObject( + "/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 1", + [0b0100_0000]); + using var surface = Render(image); + AssertNear(new Rgba32(0, 0, 0, 255), surface[25, 50], 0); + AssertNear(new Rgba32(255, 255, 255, 255), surface[75, 50], 0); + } + + /// Proves rows of a sub-byte image are padded to whole bytes independently (width 9, height 2). + [Fact] + public void PdfDocument_Images_RawOneBitMultiRowPadding_UnpacksEachRowFromItsOwnByte() + { + // Row 0: sample 0 white; row 1: sample 8 white (first bit of its second byte). Padding bits are ignored. + var image = StreamObject( + "/Type /XObject /Subtype /Image /Width 9 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1", + [0x80, 0x00, 0x00, 0x80]); + using var surface = Render(image); + var black = new Rgba32(0, 0, 0, 255); + var white = new Rgba32(255, 255, 255, 255); + AssertNear(white, surface[5, 25], 0); + AssertNear(black, surface[16, 25], 0); + AssertNear(black, surface[94, 25], 0); + AssertNear(black, surface[5, 75], 0); + AssertNear(black, surface[83, 75], 0); + AssertNear(white, surface[94, 75], 0); + } + + /// Proves a 4-bit DeviceRGB base image keeps its three components per pixel. + [Fact] + public void PdfDocument_Images_RawFourBitRgb_UnpacksComponents() + { + var image = StreamObject( + "/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 4", + [0xF0, 0x00, 0xFF]); + using var surface = Render(image); + AssertNear(new Rgba32(255, 0, 0, 255), surface[25, 50], 0); + AssertNear(new Rgba32(0, 255, 255, 255), surface[75, 50], 0); + } + + /// Proves a 2-bit DeviceRGB base image is padded per row (3 samples of 2 bits fit in one byte, 2 rows). + [Fact] + public void PdfDocument_Images_RawTwoBitRgbTwoRows_PadsEachRow() + { + // 1 pixel wide: 3 samples x 2 bits = 6 bits, padded to one byte per row (the two padding bits are ignored). + var image = StreamObject( + "/Type /XObject /Subtype /Image /Width 1 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 2", + [0b1100_0011, 0b0011_1100]); + using var surface = Render(image); + AssertNear(new Rgba32(255, 0, 0, 255), surface[50, 25], 0); + AssertNear(new Rgba32(0, 255, 255, 255), surface[50, 75], 0); + } + + /// Proves 16-bit gray and RGB base images keep the high byte of each sample. + [Fact] + public void PdfDocument_Images_RawSixteenBit_UsesHighBytes() + { + var gray = StreamObject( + "/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 16", + [0x80, 0xFF, 0x40, 0x00]); + using (var surface = Render(gray)) + { + AssertNear(new Rgba32(128, 128, 128, 255), surface[25, 50], 0); + AssertNear(new Rgba32(64, 64, 64, 255), surface[75, 50], 0); + } + + var rgb = StreamObject( + "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 16", + [0xFF, 0x00, 0x80, 0xAA, 0x00, 0xFF]); + using var rgbSurface = Render(rgb); + AssertNear(new Rgba32(255, 128, 0, 255), rgbSurface[50, 50], 0); + } + + /// Proves a 4-bit /Indexed image looks up raw nibble indices and pads its row. + [Fact] + public void PdfDocument_Images_RawFourBitIndexed_LooksUpPalette() + { + // Three 4-bit indices (0, 1, 2) in two bytes; the last nibble is row padding. + var image = StreamObject( + "/Type /XObject /Subtype /Image /Width 3 /Height 1 " + + "/ColorSpace [/Indexed /DeviceRGB 2 ] /BitsPerComponent 4", + [0x01, 0x20]); + using var surface = Render(image); + AssertNear(new Rgba32(255, 0, 0, 255), surface[16, 50], 0); + AssertNear(new Rgba32(0, 255, 0, 255), surface[50, 50], 0); + AssertNear(new Rgba32(0, 0, 255, 255), surface[83, 50], 0); + } + + /// Proves a truncated sub-byte image fails closed. + [Fact] + public void PdfDocument_Images_RawOneBitTruncated_ThrowsInvalidDataException() + { + var image = StreamObject( + "/Type /XObject /Subtype /Image /Width 9 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1", + [0xFF, 0x80, 0xFF]); + Assert.Throws(() => Render(image)); + } + + /// Proves a 16-bit /Indexed image is rejected. + [Fact] + public void PdfDocument_Images_IndexedSixteenBit_ThrowsUnsupportedImageFeatureException() + { + var image = StreamObject( + "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace [/Indexed /DeviceRGB 1 ] /BitsPerComponent 16", + [0, 0]); + Assert.Throws(() => Render(image)); + } + + /// Builds a 1x1 raw DeviceGray image object (object 5) with extra dictionary entries. + private static byte[] RawGrayImage(string entries, byte[] samples) => + StreamObject( + $"/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 {entries}", + samples); + + /// Proves JPXDecode combined with another filter fails closed. + [Fact] + public void PdfDocument_Images_Jpx_CombinedWithAnotherFilter_ThrowsInvalidDataException() + { + var jpx = Jp2(Flat(8, 8, 1), new J2kJp2Options { EnumCs = 17 }); + var image = StreamObject( + "/Type /XObject /Subtype /Image /Width 8 /Height 8 /BitsPerComponent 8 /Filter [/ASCIIHexDecode /JPXDecode]", + Encoding.ASCII.GetBytes(Convert.ToHexString(jpx) + ">")); + Assert.Throws(() => Render(image)); + } + + /// Builds a four-component JP2 (color + opacity via cdef) with flat component values. + private static byte[] AlphaJp2(int r, int g, int b, int a, int type) => + Jp2( + Flat(8, 8, r, g, b, a), + new J2kJp2Options { Cdef = [(0, 0, 1), (1, 0, 2), (2, 0, 3), (3, type, 0)] }); +} diff --git a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs index 1a3b10c7..a602b37c 100644 --- a/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs +++ b/test/DemaConsulting.CanvasNet.Pdf.Tests/PdfDocumentTests.cs @@ -347,11 +347,12 @@ private static Canvas.Surface RenderCcittFaxImage( int columns, int rows, byte[] encodedData, - string extraDecodeParms = "") + string extraDecodeParms = "", + string extraImageEntries = "") { var imageStream = BuildStreamObjectBody( $"/Type /XObject /Subtype /Image /Width {columns} /Height {rows} /ColorSpace /DeviceGray " - + $"/BitsPerComponent 8 /Filter /CCITTFaxDecode " + + $"/BitsPerComponent 8 {extraImageEntries} /Filter /CCITTFaxDecode " + $"/DecodeParms << /K -1 /Columns {columns} /Rows {rows}{extraDecodeParms} >>", encodedData); @@ -6041,11 +6042,11 @@ public void PdfDocument_Images_FlateDecodeTiffPredictor_ColumnsOverflowsRowStrid [Fact] public void PdfDocument_Images_UnsupportedFilter_ThrowsUnsupportedImageFeatureException() { - // Arrange: /JPXDecode remains genuinely unsupported (unlike /LZWDecode and - // /CCITTFaxDecode, both of which this library implements - see + // Arrange: /JBIG2Decode remains genuinely unsupported (unlike /LZWDecode, + // /CCITTFaxDecode and /JPXDecode, all of which this library implements - see // PdfFixtures/README.md/design docs). var imageStream = BuildStreamObjectBody( - "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /JPXDecode", + "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /JBIG2Decode", [1, 2, 3, 4]); var bytes = BuildSinglePagePdfWithResources( @@ -7068,6 +7069,60 @@ public void PdfDocument_Images_DoOperator_CcittFaxGroup4_PlacesExpectedPixels() Assert.Equal(black, surface[5, 3]); } + /// Proves that /Decode [1 0] on a CCITTFaxDecode image swaps black and white. + [Fact] + public void PdfDocument_Images_DoOperator_CcittFaxWithDecode_InvertsPixels() + { + // Arrange: the same hand-verified 8x4 pattern as the Group 4 test above. + var pixels = new bool[8, 4]; + for (var y = 0; y < 4; y++) + { + for (var x = 0; x < 8; x++) + { + pixels[x, y] = y switch + { + 0 or 1 => x >= 4, + 2 => x >= 3, + _ => x >= 5, + }; + } + } + + // Act + using var surface = RenderCcittFaxImage(8, 4, EncodeCcittGroup4ForTest(pixels), extraImageEntries: "/Decode [1 0]"); + + // Assert: black is now white and white is now black. + Assert.Equal(new Canvas.Rgba32(0, 0, 0, 255), surface[0, 0]); + Assert.Equal(new Canvas.Rgba32(255, 255, 255, 255), surface[4, 0]); + Assert.Equal(new Canvas.Rgba32(0, 0, 0, 255), surface[2, 2]); + Assert.Equal(new Canvas.Rgba32(255, 255, 255, 255), surface[3, 2]); + } + + /// Proves that /Decode [2 0] on a 2-bit /Indexed image remaps the palette indices. + [Fact] + public void PdfDocument_Images_DoOperator_IndexedWithDecode_RemapsIndices() + { + // Arrange: 2x1 image of 2-bit indices (0, 3) over a 3-entry palette (red/green/blue). + // /Decode [2 0] maps raw 0 -> 2 and raw 3 (the maximum) -> 0. + var imageStream = BuildStreamObjectBody( + "/Type /XObject /Subtype /Image /Width 2 /Height 1 /Decode [2 0] " + + "/ColorSpace [/Indexed /DeviceRGB 2 ] /BitsPerComponent 2 /Filter /FlateDecode", + ZlibCompress([0b0011_0000])); + var bytes = BuildSinglePagePdfWithResources( + 100, + 100, + "100 0 0 100 0 0 cm /Im0 Do", + "/XObject << /Im0 5 0 R >>", + [imageStream]); + + // Act + using var surface = RenderPdfBytes(bytes); + + // Assert + Assert.Equal(new Canvas.Rgba32(0, 0, 255, 255), surface[25, 50]); + Assert.Equal(new Canvas.Rgba32(255, 0, 0, 255), surface[75, 50]); + } + /// Proves that the default (absent) /BlackIs1 maps a physically black pixel to packed bit 0 (and a decoded gray value of 0). [Fact] public void PdfDocument_Images_DoOperator_CcittFaxBlackIs1Default_MapsZeroBitToBlack() @@ -7448,7 +7503,7 @@ public void PdfDocument_Images_UnsupportedBitsPerComponent_ThrowsUnsupportedImag { // Arrange var imageStream = BuildStreamObjectBody( - "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /FlateDecode", + "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 3 /Filter /FlateDecode", ZlibCompress([0x00])); var bytes = BuildSinglePagePdfWithResources( diff --git a/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs b/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs index 5b05967e..e7d96839 100644 --- a/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs @@ -331,6 +331,32 @@ public void CanvasNet_SystemIntegration_JpegGetInfoWithLargeLeadingSegments_Retu Assert.Equal(loaded.Height, info.Height); } + /// + /// Proves that the system can decode a real-encoder JPEG 2000 file into a Surface through + /// the public API and that its pixels equal the PNG source it was generated from. + /// Like , is decode-only, so the + /// integrated round trip is "load a real OpenJPEG-encoded fixture and compare it to the + /// source image decoded by the system's own PNG codec" (see + /// Jpeg2000Fixtures\README.md for provenance). + /// + [Fact] + public void CanvasNet_SystemIntegration_Jpeg2000Load_ReturnsExpectedPixel() + { + // Arrange: the fixture directory copied to the test output directory + var directory = System.IO.Path.Join(AppContext.BaseDirectory, "Jpeg2000Fixtures"); + using var expected = PngCodec.Load(System.IO.Path.Join(directory, "source_rgb.png")); + + // Act: load the JPEG 2000 file through the public API + using var loaded = Jpeg2000Codec.Load(System.IO.Path.Join(directory, "lossless_rct.jp2")); + + // Assert: the system produces exactly the source image + Assert.Equal(expected.Width, loaded.Width); + Assert.Equal(expected.Height, loaded.Height); + Assert.Equal(expected[0, 0], loaded[0, 0]); + Assert.Equal(expected[37, 21], loaded[37, 21]); + Assert.Equal(expected[expected.Width - 1, expected.Height - 1], loaded[loaded.Width - 1, loaded.Height - 1]); + } + /// /// Proves that the system can decode a GIF stream into a Surface through the public API, /// producing the expected integrated pixel result for a single-pixel, single-frame image. diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000CodecTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000CodecTests.cs new file mode 100644 index 00000000..31247a9d --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000CodecTests.cs @@ -0,0 +1,2558 @@ +// cspell:ignore bypass termall vcausal segsym pterm ppm ppt tlm plt crg poc cprl rpcl pcrl rlcp lrcp sop eph pclr cmap cdef bpcc colr +using System.Buffers.Binary; +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Codecs; + +namespace DemaConsulting.CanvasNet.Tests.Codecs; + +/// +/// Unit tests for the class. +/// +/// +/// The tests drive the decoder with streams produced by the independent, test-only +/// so that every coding feature of ITU-T T.800 Part 1 is exercised, +/// plus a hand-verified MQ coder vector from ITU-T T.88 that guards against symmetric +/// encoder/decoder defects. +/// +public class Jpeg2000CodecTests +{ + /// The T.88 Annex H.2 test sequence (256 decisions, MSB first). + private static readonly byte[] MqTestInput = + [0x00, 0x02, 0x00, 0x51, 0x00, 0x00, 0x00, 0xC0, 0x03, 0x52, 0x87, 0x2A, 0xAA, 0xAA, 0xAA, 0xAA, + 0x82, 0xC0, 0x20, 0x00, 0xFC, 0xD7, 0x9E, 0xF6, 0xBF, 0x7F, 0xED, 0x90, 0x4F, 0x46, 0xA3, 0xBF]; + + /// The T.88 Annex H.2 encoded sequence (without the terminating marker). + private static readonly byte[] MqTestOutput = + [0x84, 0xC7, 0x3B, 0xFC, 0xE1, 0xA1, 0x43, 0x04, 0x02, 0x20, 0x00, 0x00, 0x41, 0x0D, 0xBB, 0x86, + 0xF4, 0x31, 0x7F, 0xFF, 0x88, 0xFF, 0x37, 0x47, 0x1A, 0xDB, 0x6A, 0xDF]; + + /// Explicit work cap for fuzzed streams so every mutation is bounded by a budget rather than by wall-clock time. + private static readonly Jpeg2000DecoderLimits FuzzLimits = new() { MaxTier1Work = 1L << 28 }; + + private static readonly Type[] AllowedFailures = [typeof(InvalidDataException), typeof(UnsupportedImageFeatureException)]; + + // ------------------------------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------------------------------ + + private static J2kOptions Rev(int levels = 3) => new() { Levels = levels }; + + private static J2kOptions Irrev(int levels = 3) => new() { Levels = levels, Reversible = false }; + + private static byte[] Encode(J2kImage image, J2kOptions options) => Jpeg2000TestEncoder.Encode(image, options); + + private static int Floor(int a, int b) => (int)Math.Floor(a / (double)b); + + /// Computes the byte the decoder is expected to produce for pixel (x, y) of component c. + private static int Expected(J2kImage image, int c, int x, int y) + { + var comp = image.Components[c]; + var cx = Math.Clamp(Floor(image.XOsiz + x, comp.Dx) - image.CompX0(c), 0, image.CompWidth(c) - 1); + var cy = Math.Clamp(Floor(image.YOsiz + y, comp.Dy) - image.CompY0(c), 0, image.CompHeight(c) - 1); + return Jpeg2000TestEncoder.ExpectedByte(comp.Samples[(cy * image.CompWidth(c)) + cx], comp.Depth, comp.Signed); + } + + /// Decodes and returns the largest absolute sample error against the source image. + private static int MaxError(J2kImage image, byte[] data) + { + var decoded = Jpeg2000Codec.Decode(data); + Assert.Equal(image.Width, decoded.Width); + Assert.Equal(image.Height, decoded.Height); + Assert.Equal(image.Components.Length, decoded.ColorChannelCount); + var max = 0; + var n = decoded.ColorChannelCount; + for (var y = 0; y < image.Height; y++) + { + for (var x = 0; x < image.Width; x++) + { + for (var c = 0; c < n; c++) + { + var got = decoded.ColorSamples[((((y * image.Width) + x) * n) + c)]; + max = Math.Max(max, Math.Abs(got - Expected(image, c, x, y))); + } + } + } + + return max; + } + + private static void AssertExact(J2kImage image, J2kOptions options) + { + var data = Encode(image, options); + Assert.Equal(0, MaxError(image, data)); + } + + private static void AssertClose(J2kImage image, J2kOptions options, int tolerance = 3) + { + var data = Encode(image, options); + Assert.True(MaxError(image, data) <= tolerance); + } + + private static J2kImage Img(int w, int h, int comps = 3, int depth = 8, int seed = 1, int noise = 24, bool signed = false) => + Jpeg2000TestEncoder.MakeImage(w, h, comps, depth, seed, noise, signed); + + /// + /// Asserts that decoding fails with exactly , that the message names the + /// expected cause and that the failure comes from explicit validation (no wrapped inner exception). + /// + private static void AssertMalformed(byte[] data, string cause, Jpeg2000DecoderLimits? limits = null) + { + var ex = Assert.Throws(() => Jpeg2000Codec.Decode(data, limits ?? Jpeg2000DecoderLimits.Default)); + Assert.Contains(cause, ex.Message); + Assert.Null(ex.InnerException); + } + + /// + /// Outcome check for randomly mutated streams only. A random mutation of an entropy-coded payload or of an + /// unrelated header field can still be a valid stream, so success is legitimate here; any failure must be + /// one of the documented exception types raised by explicit validation, never a wrapped runtime error. + /// + private static void AssertFuzzOutcome(byte[] data) + { + try + { + _ = Jpeg2000Codec.Decode(data, FuzzLimits); + } + catch (Exception ex) when (AllowedFailures.Contains(ex.GetType())) + { + Assert.Null(ex.InnerException); + } + } + + /// + /// Builds a hostile one-tile, no-decomposition stream of x + /// pixels whose every code-block (2^blockExp square) declares (6 to 164) coding + /// passes in a single one-byte segment, with a bit-plane count that allows them. The stream stays tiny + /// compared with the entropy-decoding work it asks for. + /// + private static byte[] HostileTier1Stream(int size, int blockExp, int passes = 88) + { + var o = Rev(0); + o.CodeBlockWidthExp = blockExp; + o.CodeBlockHeightExp = blockExp; + var data = Encode(Img(8, 8, 1), o); + var siz = FindMarker(data, 0xFF51); + var qcd = FindMarker(data, 0xFF5C); + var sot = FindMarker(data, 0xFF90); + var sod = FindMarker(data, 0xFF93) + 2; + byte[] dim = [(byte)(size >> 24), (byte)(size >> 16), (byte)(size >> 8), (byte)size]; + + // Two guard bits and an exponent of 29 give 30 bit-planes: 3 * 30 - 2 = 88 passes are legal. + var header = Patch(Patch(Patch(Patch(Patch(Patch(data[..sod], siz + 6, dim), siz + 10, dim), siz + 22, dim), siz + 26, dim), qcd + 4, 0x40, 29 << 3), sot + 6, 0, 0, 0, 0); + var blocks = size >> blockExp; + var inclusion = new Jpeg2000TestEncoder.TagTreeEncoder(blocks, blocks, new int[blocks * blocks]); + var zeroBits = new Jpeg2000TestEncoder.TagTreeEncoder(blocks, blocks, new int[blocks * blocks]); + var bits = new Jpeg2000TestEncoder.HeaderBitWriter(); + bits.Bit(1); + for (var y = 0; y < blocks; y++) + { + for (var x = 0; x < blocks; x++) + { + inclusion.Encode(bits, x, y, 1); + zeroBits.Encode(bits, x, y, 1); + if (passes >= 37) + { + bits.Bits(0x1FF, 9); + bits.Bits(passes - 37, 7); + } + else + { + bits.Bits(0xF, 4); + bits.Bits(passes - 6, 5); + } + + bits.Bit(0); + bits.Bits(1, 3 + (31 - int.LeadingZeroCount(passes))); + } + } + + var packet = bits.Finish(); + return [.. header, .. packet, .. new byte[blocks * blocks], 0xFF, 0xD9]; + } + + // ------------------------------------------------------------------------------------------ + // MQ coder (T.88) + // ------------------------------------------------------------------------------------------ + + /// + /// Tests that the test encoder's MQ coder reproduces the T.88 Annex H.2 reference code stream. + /// + [Fact] + public void Jpeg2000Codec_MqEncoder_T88TestSequence_MatchesReferenceCodeStream() + { + var enc = new Jpeg2000TestEncoder.MqEncoder(); + enc.ResetContexts(false); + enc.Start(); + foreach (var b in MqTestInput) + { + for (var bit = 7; bit >= 0; bit--) + { + enc.Encode((b >> bit) & 1, 1); + } + } + + Assert.Equal(MqTestOutput, enc.Flush()); + } + + /// + /// Tests that the production MQ decoder recovers the T.88 Annex H.2 test sequence from the reference code stream. + /// + [Fact] + public void Jpeg2000Codec_MqDecoder_T88ReferenceCodeStream_RecoversTestSequence() + { + var dec = new Jpeg2000Codec.MqDecoder(); + dec.ResetContexts(); + dec.Init(MqTestOutput, 0, MqTestOutput.Length); + var decoded = new byte[MqTestInput.Length]; + for (var i = 0; i < decoded.Length * 8; i++) + { + decoded[i / 8] = (byte)((decoded[i / 8] << 1) | dec.DecodeBit(1)); + } + + Assert.Equal(MqTestInput, decoded); + } + + // ------------------------------------------------------------------------------------------ + // Reversible (5/3) round trips + // ------------------------------------------------------------------------------------------ + + /// Tests lossless coding of a single-component image with no decomposition. + [Fact] + public void Jpeg2000Codec_Decode_ReversibleNoDecomposition_RoundTripsExactly() => + AssertExact(Img(23, 17, 1), Rev(0)); + + /// Tests lossless coding with different decomposition levels. + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(5)] + public void Jpeg2000Codec_Decode_ReversibleLevels_RoundTripsExactly(int levels) => + AssertExact(Img(61, 47, 1), Rev(levels)); + + /// Tests lossless coding of odd and tiny image sizes. + [Theory] + [InlineData(1, 1)] + [InlineData(1, 9)] + [InlineData(9, 1)] + [InlineData(2, 2)] + [InlineData(3, 5)] + [InlineData(65, 33)] + public void Jpeg2000Codec_Decode_ReversibleOddSizes_RoundTripsExactly(int w, int h) => + AssertExact(Img(w, h, 1), Rev(4)); + + /// Tests lossless coding with the reversible component transform. + [Fact] + public void Jpeg2000Codec_Decode_ReversibleRct_RoundTripsExactly() + { + var o = Rev(); + o.Mct = true; + AssertExact(Img(40, 30), o); + } + + /// Tests lossless coding of a four-component image. + [Fact] + public void Jpeg2000Codec_Decode_FourComponents_RoundTripsExactly() => + AssertExact(Img(33, 21, 4), Rev()); + + /// Tests lossless coding with large non-square code-blocks. + [Theory] + [InlineData(2, 2)] + [InlineData(6, 4)] + [InlineData(4, 6)] + [InlineData(10, 2)] + public void Jpeg2000Codec_Decode_CodeBlockSizes_RoundTripsExactly(int xcb, int ycb) + { + var o = Rev(); + o.CodeBlockWidthExp = xcb; + o.CodeBlockHeightExp = ycb; + AssertExact(Img(90, 70, 1), o); + } + + /// Tests lossless coding at other bit depths and signedness. + [Theory] + [InlineData(1, false)] + [InlineData(4, false)] + [InlineData(10, false)] + [InlineData(12, true)] + [InlineData(16, false)] + [InlineData(16, true)] + [InlineData(8, true)] + public void Jpeg2000Codec_Decode_DepthAndSign_ScalesToEightBits(int depth, bool signed) + { + var o = Rev(); + AssertExact(Img(37, 29, 1, depth, 5, depth > 2 ? 3 : 0, signed), o); + } + + /// Tests that a signed multi-component image with RCT round trips. + [Fact] + public void Jpeg2000Codec_Decode_SignedRct_RoundTripsExactly() + { + var o = Rev(); + o.Mct = true; + AssertExact(Img(21, 19, 3, 8, 9, 10, true), o); + } + + // ------------------------------------------------------------------------------------------ + // Irreversible (9/7) + // ------------------------------------------------------------------------------------------ + + /// Tests the 9/7 path with expounded quantization. + [Fact] + public void Jpeg2000Codec_Decode_Irreversible_WithinTolerance() => + AssertClose(Img(64, 48, 1), Irrev()); + + /// Tests the 9/7 path with the ICT and non-zero step size mantissas. + [Fact] + public void Jpeg2000Codec_Decode_IrreversibleIctMantissa_WithinTolerance() + { + var o = Irrev(); + o.Mct = true; + o.QuantMantissa = true; + AssertClose(Img(50, 41), o, 4); + } + + /// Tests the 9/7 path with derived quantization. + [Fact] + public void Jpeg2000Codec_Decode_IrreversibleDerivedQuantization_WithinTolerance() + { + var o = Irrev(4); + o.QuantStyle = 1; + o.QuantMantissa = true; + AssertClose(Img(70, 50, 1), o, 6); + } + + /// Tests the 9/7 path at 12 bits. + [Fact] + public void Jpeg2000Codec_Decode_IrreversibleTwelveBit_WithinTolerance() => + AssertClose(Img(33, 33, 1, 12, 2, 20), Irrev(), 2); + + // ------------------------------------------------------------------------------------------ + // Code-block styles + // ------------------------------------------------------------------------------------------ + + /// Tests every code-block style flag and several combinations. + [Theory] + [InlineData(0x01)] + [InlineData(0x02)] + [InlineData(0x04)] + [InlineData(0x08)] + [InlineData(0x10)] + [InlineData(0x20)] + [InlineData(0x01 | 0x04)] + [InlineData(0x01 | 0x02)] + [InlineData(0x01 | 0x08)] + [InlineData(0x04 | 0x08 | 0x20)] + [InlineData(0x3F)] + public void Jpeg2000Codec_Decode_CodeBlockStyle_RoundTripsExactly(int style) + { + var o = Rev(); + o.CodeBlockStyle = style; + AssertExact(Img(48, 40, 1, 8, 3, 40), o); + } + + /// Tests the code-block styles with multiple layers (segment splitting across packets). + [Theory] + [InlineData(0x01)] + [InlineData(0x04)] + [InlineData(0x05)] + [InlineData(0x08)] + public void Jpeg2000Codec_Decode_CodeBlockStyleWithLayers_RoundTripsExactly(int style) + { + var o = Rev(); + o.CodeBlockStyle = style; + o.Layers = 5; + AssertExact(Img(48, 40, 1, 8, 4, 40), o); + } + + /// Tests the code-block styles on the irreversible path. + [Fact] + public void Jpeg2000Codec_Decode_IrreversibleBypassTermall_WithinTolerance() + { + var o = Irrev(); + o.CodeBlockStyle = 0x01 | 0x04 | 0x08; + AssertClose(Img(40, 40, 1), o); + } + + // ------------------------------------------------------------------------------------------ + // Tiles, precincts, layers, progressions + // ------------------------------------------------------------------------------------------ + + /// Tests tiling. + [Theory] + [InlineData(32, 32)] + [InlineData(17, 13)] + [InlineData(1, 1)] + [InlineData(100, 20)] + public void Jpeg2000Codec_Decode_Tiles_RoundTripsExactly(int tw, int th) + { + var o = Rev(2); + o.TileWidth = tw; + o.TileHeight = th; + AssertExact(Img(70, 45, 3), o); + } + + /// Tests tiling with a tile grid offset and an image offset. + [Fact] + public void Jpeg2000Codec_Decode_TileAndImageOffsets_RoundTripsExactly() + { + var image = Img(50, 37, 3); + image.XOsiz = 5; + image.YOsiz = 3; + image.Xsiz += 5; + image.Ysiz += 3; + var o = Rev(2); + o.TileWidth = 20; + o.TileHeight = 16; + o.TileXOffset = 4; + o.TileYOffset = 2; + AssertExact(image, o); + } + + /// Tests precincts. + [Theory] + [InlineData(4, 4)] + [InlineData(5, 3)] + [InlineData(6, 6)] + public void Jpeg2000Codec_Decode_Precincts_RoundTripsExactly(int ppx, int ppy) + { + var o = Rev(3); + o.Precincts = [(ppx, ppy)]; + o.CodeBlockWidthExp = 3; + o.CodeBlockHeightExp = 3; + AssertExact(Img(80, 70, 2 + 1), o); + } + + /// Tests per-resolution precinct sizes. + [Fact] + public void Jpeg2000Codec_Decode_PerResolutionPrecincts_RoundTripsExactly() + { + var o = Rev(3); + o.Precincts = [(3, 3), (4, 4), (5, 5), (6, 6)]; + o.CodeBlockWidthExp = 3; + o.CodeBlockHeightExp = 3; + o.Layers = 2; + AssertExact(Img(100, 90, 1), o); + } + + /// Tests multiple quality layers. + [Theory] + [InlineData(2)] + [InlineData(3)] + [InlineData(7)] + public void Jpeg2000Codec_Decode_Layers_RoundTripsExactly(int layers) + { + var o = Rev(); + o.Layers = layers; + AssertExact(Img(45, 38, 3), o); + } + + /// Tests every progression order with layers, precincts, tiles and subsampled components. + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void Jpeg2000Codec_Decode_ProgressionOrders_RoundTripsExactly(int order) + { + var o = Rev(3); + o.Progression = order; + o.Layers = 3; + o.Precincts = [(4, 4), (5, 4), (5, 5), (6, 5)]; + o.CodeBlockWidthExp = 3; + o.CodeBlockHeightExp = 3; + o.TileWidth = 50; + o.TileHeight = 41; + var image = Img(83, 77, 3); + image.Components[1].Dx = 2; + image.Components[1].Dy = 2; + image.Components[2].Dx = 3; + image.Components[2].Dy = 1; + Resample(image); + AssertExact(image, o); + } + + /// Tests every progression order with the image origin offset from zero. + [Theory] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void Jpeg2000Codec_Decode_PositionProgressionWithOffsets_RoundTripsExactly(int order) + { + var o = Rev(2); + o.Progression = order; + o.Precincts = [(4, 4)]; + o.CodeBlockWidthExp = 3; + o.CodeBlockHeightExp = 3; + o.TileWidth = 40; + o.TileHeight = 40; + o.TileXOffset = 3; + o.TileYOffset = 5; + var image = Img(60, 55, 3); + image.XOsiz = 7; + image.YOsiz = 9; + image.Xsiz += 7; + image.Ysiz += 9; + image.Components[1].Dx = 2; + image.Components[2].Dy = 2; + Resample(image); + AssertExact(image, o); + } + + /// Replaces the samples of a subsampled image by a grid of the right size. + private static void Resample(J2kImage image) + { + var rng = new Random(11); + for (var c = 0; c < image.Components.Length; c++) + { + var n = image.CompWidth(c) * image.CompHeight(c); + var s = new int[n]; + for (var i = 0; i < n; i++) + { + s[i] = Math.Clamp(((i * 5) % 200) + rng.Next(0, 40), 0, 255); + } + + image.Components[c].Samples = s; + } + } + + /// Tests subsampled components. + [Theory] + [InlineData(2, 2)] + [InlineData(1, 2)] + [InlineData(4, 4)] + [InlineData(3, 5)] + public void Jpeg2000Codec_Decode_SubsampledComponents_UpsamplesToImageGrid(int dx, int dy) + { + var image = Img(47, 39, 3); + image.Components[1].Dx = dx; + image.Components[1].Dy = dy; + image.Components[2].Dx = dx; + image.Components[2].Dy = dy; + Resample(image); + AssertExact(image, Rev(2)); + } + + /// Tests per-component decomposition levels (COC/QCC). + [Fact] + public void Jpeg2000Codec_Decode_PerComponentLevels_RoundTripsExactly() + { + var o = Rev(3); + o.ComponentLevels = [3, 1, 2]; + AssertExact(Img(50, 40, 3), o); + } + + /// Tests per-component levels on the irreversible path (QCC with differing steps). + [Fact] + public void Jpeg2000Codec_Decode_PerComponentLevelsIrreversible_WithinTolerance() + { + var o = Irrev(3); + o.ComponentLevels = [3, 1, 2]; + AssertClose(Img(50, 40, 3), o); + } + + /// Tests per-tile coding overrides. + [Fact] + public void Jpeg2000Codec_Decode_TileHeaderOverrides_RoundTripsExactly() + { + var o = Rev(2); + o.TileWidth = 32; + o.TileHeight = 32; + var t1 = o.Clone(); + t1.Levels = 1; + t1.CodeBlockStyle = 0x04; + t1.Progression = 3; + t1.Layers = 2; + o.TileOverrides[1] = t1; + var t2 = o.Clone(); + t2.Mct = false; + t2.Levels = 0; + o.TileOverrides[2] = t2; + AssertExact(Img(70, 40, 3), o); + } + + // ------------------------------------------------------------------------------------------ + // Quantization entry counts (style 0) and COD/COC, QCD/QCC precedence + // ------------------------------------------------------------------------------------------ + + private const int MarkerQcdValue = 0xFF5C; + private const int MarkerQccValue = 0xFF5D; + private const int MarkerCodValue = 0xFF52; + + /// Finds the offset of a marker segment in the main header or in the first tile-part header. + private static int FindSegment(byte[] d, int marker, bool inTile) + { + var pos = 2; + var tile = false; + while (pos + 4 <= d.Length) + { + var m = (d[pos] << 8) | d[pos + 1]; + if (m == 0xFF93) + { + break; + } + + var len = (d[pos + 2] << 8) | d[pos + 3]; + if (m == marker && tile == inTile) + { + return pos; + } + + tile |= m == 0xFF90; + pos += 2 + len; + } + + throw new InvalidOperationException("marker segment not found."); + } + + /// Adds (positive) or removes (negative) trailing payload bytes of a marker segment, fixing its length. + private static byte[] ResizeSegment(byte[] d, int marker, bool inTile, int delta) + { + var pos = FindSegment(d, marker, inTile); + var len = (d[pos + 2] << 8) | d[pos + 3]; + var end = pos + 2 + len; + var list = d.ToList(); + if (delta > 0) + { + list.InsertRange(end, Enumerable.Repeat((byte)0x40, delta)); + } + else + { + list.RemoveRange(end + delta, -delta); + } + + list[pos + 2] = (byte)((len + delta) >> 8); + list[pos + 3] = (byte)(len + delta); + return [.. list]; + } + + /// Moves the QCD segment of the main header in front of the COD segment. + private static byte[] MoveQcdBeforeCod(byte[] d) + { + var qcd = FindSegment(d, MarkerQcdValue, false); + var qcdLen = 2 + ((d[qcd + 2] << 8) | d[qcd + 3]); + var segment = d[qcd..(qcd + qcdLen)]; + var rest = d[..qcd].Concat(d[(qcd + qcdLen)..]).ToArray(); + var cod = FindSegment(rest, MarkerCodValue, false); + return [.. rest[..cod], .. segment, .. rest[cod..]]; + } + + /// A single-tile reversible stream with a main COC/QCC (component 1 has one level) and an optional tile override. + private static byte[] QuantStream(bool tileOverride, bool tileComponentLevels = false) + { + var o = Rev(2); + o.ComponentLevels = [2, 1, 2]; + o.ZeroPsotOnLast = true; + o.OmitEoc = true; + if (tileOverride) + { + var t = o.Clone(); + t.ComponentLevels = tileComponentLevels ? [2, 1, 2] : null; + o.ComponentLevels = [2, 2, 2]; + o.TileOverrides[0] = t; + o.TileWidth = 0; + } + + return Encode(Img(40, 30, 3), o); + } + + /// Tests that a style-0 QCD or QCC with extra or missing entries is rejected in the main header. + [Theory] + [InlineData(MarkerQcdValue, 1)] + [InlineData(MarkerQcdValue, 3)] + [InlineData(MarkerQcdValue, -1)] + [InlineData(MarkerQccValue, 1)] + [InlineData(MarkerQccValue, -1)] + public void Jpeg2000Codec_Decode_MainQuantEntryCountMismatch_ThrowsInvalidData(int marker, int delta) + { + var data = ResizeSegment(QuantStream(false), marker, false, delta); + AssertMalformed(data, "quantization segment has"); + } + + /// Tests that a style-0 QCD or QCC with extra or missing entries is rejected in a tile-part header. + [Theory] + [InlineData(MarkerQcdValue, 1)] + [InlineData(MarkerQcdValue, -1)] + [InlineData(MarkerQccValue, 1)] + [InlineData(MarkerQccValue, -1)] + public void Jpeg2000Codec_Decode_TileQuantEntryCountMismatch_ThrowsInvalidData(int marker, int delta) + { + var data = ResizeSegment(QuantStream(true, true), marker, true, delta); + AssertMalformed(data, "quantization segment has"); + } + + /// Tests that the unmodified streams used by the mismatch tests decode. + [Fact] + public void Jpeg2000Codec_Decode_QuantEntryCountMatches_RoundTripsExactly() + { + Assert.Equal(40, Jpeg2000Codec.Decode(QuantStream(false)).Width); + Assert.Equal(40, Jpeg2000Codec.Decode(QuantStream(true, true)).Width); + } + + /// Tests that a QCD placed before the COD is validated against the levels the COD later signals. + [Fact] + public void Jpeg2000Codec_Decode_QcdBeforeCod_ValidatedAgainstLaterLevels() + { + var image = Img(40, 30, 3); + var data = MoveQcdBeforeCod(Encode(image, Rev(2))); + Assert.Equal(0, MaxError(image, data)); + + AssertMalformed(ResizeSegment(data, MarkerQcdValue, false, 1), "quantization segment has"); + AssertMalformed(ResizeSegment(data, MarkerQcdValue, false, -1), "quantization segment has"); + } + + /// T.800 A.6.2: a tile COD (and QCD) overrides a main COC (and QCC) for all components. + [Fact] + public void Jpeg2000Codec_Decode_TileCodOverridesMainCoc_ForAllComponents() + { + var o = Rev(3); + o.ComponentLevels = [3, 1, 3]; + var tile = o.Clone(); + tile.Levels = 2; + tile.ComponentLevels = null; + o.TileOverrides[0] = tile; + AssertExact(Img(40, 30, 3), o); + } + + /// T.800 A.6.2: a tile-part COC (and QCC) beats the tile COD (and QCD) for its component. + [Fact] + public void Jpeg2000Codec_Decode_TileCocOverridesTileCod_ForItsComponent() + { + var o = Rev(3); + var tile = o.Clone(); + tile.Levels = 2; + tile.ComponentLevels = [2, 1, 2]; + o.TileOverrides[0] = tile; + AssertExact(Img(40, 30, 3), o); + } + + /// Tests that without a tile COD the main COC still applies to its component, in tiles that have no override. + [Fact] + public void Jpeg2000Codec_Decode_NoTileCod_MainCocStillApplies() + { + var o = Rev(3); + o.ComponentLevels = [3, 1, 3]; + o.TileWidth = 32; + o.TileHeight = 32; + var tile = o.Clone(); + tile.Levels = 2; + tile.ComponentLevels = null; + o.TileOverrides[1] = tile; + AssertExact(Img(64, 32, 3), o); + } + + /// Tests the same precedence on the irreversible path (expounded QCD/QCC with per-component levels). + [Fact] + public void Jpeg2000Codec_Decode_TilePrecedenceIrreversible_WithinTolerance() + { + var o = Irrev(3); + o.ComponentLevels = [3, 1, 3]; + o.TileWidth = 32; + o.TileHeight = 32; + var tile = o.Clone(); + tile.Levels = 2; + tile.ComponentLevels = [2, 2, 1]; + o.TileOverrides[1] = tile; + AssertClose(Img(64, 32, 3), o); + } + + // ------------------------------------------------------------------------------------------ + // POC, SOP/EPH, PPM/PPT, tile-parts, misc markers + // ------------------------------------------------------------------------------------------ + + private static List TestPoc(int layers, int comps) => + [ + new(0, 0, layers, 2, comps, 4), + new(0, 0, Math.Max(1, layers - 1), 4, comps, 1), + new(0, 0, layers, 4, comps, 0), + ]; + + /// Tests progression order changes in the main header. + [Fact] + public void Jpeg2000Codec_Decode_PocMainHeader_RoundTripsExactly() + { + var o = Rev(3); + o.Layers = 3; + o.Poc = TestPoc(3, 3); + AssertExact(Img(40, 40, 3), o); + } + + /// Tests progression order changes in the main header with several tiles. + [Fact] + public void Jpeg2000Codec_Decode_PocMainHeaderWithTiles_RoundTripsExactly() + { + var o = Rev(3); + o.Layers = 3; + o.Poc = TestPoc(3, 3); + o.TileWidth = 24; + o.TileHeight = 24; + AssertExact(Img(50, 40, 3), o); + } + + /// Tests progression order changes in the tile-part header. + [Fact] + public void Jpeg2000Codec_Decode_PocTileHeader_RoundTripsExactly() + { + var o = Rev(3); + o.Layers = 3; + o.Poc = TestPoc(3, 3); + o.PocInTileHeader = true; + o.TileWidth = 24; + o.TileHeight = 24; + AssertExact(Img(50, 40, 3), o); + } + + /// Tests that a tile-header POC replaces the main-header POC. + [Fact] + public void Jpeg2000Codec_Decode_PocTileOverridesMain_RoundTripsExactly() + { + var o = Rev(3); + o.Layers = 2; + o.Poc = TestPoc(2, 3); + o.TileWidth = 24; + o.TileHeight = 40; + var t = o.Clone(); + t.Poc = [new J2kPoc(0, 0, 2, 4, 3, 3)]; + o.TileOverrides[1] = t; + AssertExact(Img(48, 40, 3), o); + } + + /// Tests SOP and EPH markers. + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void Jpeg2000Codec_Decode_SopEph_RoundTripsExactly(bool sop, bool eph) + { + var o = Rev(2); + o.Sop = sop; + o.Eph = eph; + o.Layers = 2; + o.TileWidth = 30; + o.TileHeight = 30; + AssertExact(Img(50, 40, 3), o); + } + + /// Tests packed packet headers in PPM marker segments. + [Theory] + [InlineData(0, 1)] + [InlineData(7, 1)] + [InlineData(40, 3)] + public void Jpeg2000Codec_Decode_Ppm_RoundTripsExactly(int chunk, int tileParts) + { + var o = Rev(2); + o.HeaderMode = J2kHeaderMode.Ppm; + o.PackedChunkSize = chunk; + o.TileParts = tileParts; + o.Layers = 2; + o.Eph = true; + o.TileWidth = 30; + o.TileHeight = 30; + AssertExact(Img(50, 40, 3), o); + } + + /// Tests packed packet headers in PPT marker segments. + [Theory] + [InlineData(0, 1)] + [InlineData(5, 1)] + [InlineData(30, 4)] + public void Jpeg2000Codec_Decode_Ppt_RoundTripsExactly(int chunk, int tileParts) + { + var o = Rev(2); + o.HeaderMode = J2kHeaderMode.Ppt; + o.PackedChunkSize = chunk; + o.TileParts = tileParts; + o.Layers = 2; + o.Sop = true; + o.TileWidth = 30; + o.TileHeight = 30; + AssertExact(Img(50, 40, 3), o); + } + + /// Tests multiple tile-parts per tile. + [Fact] + public void Jpeg2000Codec_Decode_TileParts_RoundTripsExactly() + { + var o = Rev(3); + o.TileParts = 4; + o.Layers = 3; + o.TileWidth = 40; + o.TileHeight = 40; + AssertExact(Img(70, 50, 3), o); + } + + /// Tests TLM, PLT, CRG, COM and reserved markers. + [Fact] + public void Jpeg2000Codec_Decode_ExtraMarkers_AreSkipped() + { + var o = Rev(2); + o.ExtraMarkers = true; + o.TileParts = 2; + o.Layers = 2; + o.TileWidth = 40; + o.TileHeight = 40; + AssertExact(Img(70, 50, 3), o); + } + + /// Tests a final tile-part with Psot = 0 and no EOC marker. + [Theory] + [InlineData(true, true)] + [InlineData(false, true)] + [InlineData(true, false)] + public void Jpeg2000Codec_Decode_ZeroPsotMissingEoc_RoundTripsExactly(bool zeroPsot, bool omitEoc) + { + var o = Rev(2); + o.ZeroPsotOnLast = zeroPsot; + o.OmitEoc = omitEoc; + o.TileWidth = 40; + o.TileHeight = 40; + AssertExact(Img(70, 50, 3), o); + } + + // ------------------------------------------------------------------------------------------ + // ROI + // ------------------------------------------------------------------------------------------ + + /// Tests maximum-shift region of interest coding. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Jpeg2000Codec_Decode_RoiMaxShift_RoundTripsExactly(bool layers) + { + var o = Rev(3); + o.RoiShift = 13; + o.Layers = layers ? 3 : 1; + AssertExact(Img(50, 44, 1), o); + } + + /// Tests ROI on a subset of the components. + [Fact] + public void Jpeg2000Codec_Decode_RoiOnSubsetOfComponents_RoundTripsExactly() + { + var o = Rev(2); + o.RoiShift = 13; + o.RoiComponents = [1]; + AssertExact(Img(40, 40, 3), o); + } + + /// Tests ROI on the irreversible path. + [Fact] + public void Jpeg2000Codec_Decode_RoiIrreversible_WithinTolerance() + { + var o = Irrev(3); + o.RoiShift = 14; + AssertClose(Img(40, 40, 1), o); + } + + // ------------------------------------------------------------------------------------------ + // JP2 container + // ------------------------------------------------------------------------------------------ + + /// Tests a JP2 wrapper with an sRGB color specification. + [Fact] + public void Jpeg2000Codec_Decode_Jp2Srgb_ReportsColorSpace() + { + var image = Img(20, 15, 3); + var jp2 = Jpeg2000TestEncoder.WrapJp2(image, Encode(image, Rev(2)), new J2kJp2Options { WriteBpcc = true }); + var decoded = Jpeg2000Codec.Decode(jp2); + Assert.Equal(Jpeg2000ColorSpace.Srgb, decoded.ColorSpace); + Assert.False(decoded.HasAlpha); + Assert.Equal(0, MaxError(image, jp2)); + } + + /// Tests a grayscale JP2 and a CMYK JP2. + [Theory] + [InlineData(17, 1, Jpeg2000ColorSpace.Gray)] + [InlineData(12, 4, Jpeg2000ColorSpace.Cmyk)] + public void Jpeg2000Codec_Decode_Jp2EnumeratedColorSpace_ReportsColorSpace(int enumCs, int comps, Jpeg2000ColorSpace expected) + { + var image = Img(20, 15, comps); + var jp2 = Jpeg2000TestEncoder.WrapJp2(image, Encode(image, Rev(2)), new J2kJp2Options { EnumCs = enumCs }); + Assert.Equal(expected, Jpeg2000Codec.Decode(jp2).ColorSpace); + Assert.Equal(0, MaxError(image, jp2)); + } + + /// Tests that sYCC data is converted to sRGB. + [Fact] + public void Jpeg2000Codec_Decode_Jp2Sycc_ConvertsToSrgb() + { + var image = Img(8, 8, 3, 8, 1, 0); + foreach (var c in image.Components) + { + Array.Fill(c.Samples, 128); + } + + var jp2 = Jpeg2000TestEncoder.WrapJp2(image, Encode(image, Rev(1)), new J2kJp2Options { EnumCs = 18 }); + var decoded = Jpeg2000Codec.Decode(jp2); + Assert.Equal(Jpeg2000ColorSpace.Srgb, decoded.ColorSpace); + Assert.All(decoded.ColorSamples, v => Assert.InRange((int)v, 126, 130)); + } + + /// Tests that an ICC profile is reported. + [Fact] + public void Jpeg2000Codec_Decode_Jp2IccProfile_IsReported() + { + var image = Img(10, 10, 3); + byte[] icc = [1, 2, 3, 4, 5, 6, 7, 8]; + var jp2 = Jpeg2000TestEncoder.WrapJp2(image, Encode(image, Rev(1)), new J2kJp2Options { Icc = icc }); + Assert.Equal(icc, Jpeg2000Codec.Decode(jp2).IccProfile); + } + + /// Tests extended-length and to-end-of-file codestream boxes. + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + public void Jpeg2000Codec_Decode_Jp2BoxLengthForms_AreHandled(bool extended, bool toEnd) + { + var image = Img(10, 10, 1); + var jp2 = Jpeg2000TestEncoder.WrapJp2(image, Encode(image, Rev(1)), new J2kJp2Options { EnumCs = 17, ExtendedLength = extended, ZeroLength = toEnd }); + Assert.Equal(0, MaxError(image, jp2)); + } + + /// Tests that a JP2 signature box with the wrong content bytes is rejected. + [Fact] + public void Jpeg2000Codec_Decode_Jp2BadSignatureContent_ThrowsInvalidData() + { + var image = Img(8, 8, 1); + var jp2 = Jpeg2000TestEncoder.WrapJp2(image, Encode(image, Rev(1)), new J2kJp2Options { EnumCs = 17 }); + _ = Jpeg2000Codec.Decode(jp2); + AssertMalformed(Patch(jp2, 8, 0x0D, 0x0A, 0x87, 0x0B), "invalid JP2 signature box content"); + AssertMalformed(Patch(jp2, 8, 0x00, 0x00, 0x00, 0x00), "invalid JP2 signature box content"); + } + + /// Tests that a palette column declared signed is rejected as unsupported. + [Fact] + public void Jpeg2000Codec_Decode_Jp2SignedPaletteColumn_ThrowsUnsupported() + { + var image = Img(12, 9, 1, 4, 1, 0); + int[][] palette = Enumerable.Range(0, 16).Select(i => new[] { i }).ToArray(); + + // A depth of 129 is written as the byte 0x80: bit depth 1 with the signed flag set. + var jp2 = Jpeg2000TestEncoder.WrapJp2( + image, + Encode(image, Rev(1)), + new J2kJp2Options { Palette = palette, PaletteDepths = [129], Cmap = [(0, 1, 0)] }); + Assert.Throws(() => Jpeg2000Codec.Decode(jp2)); + } + + /// Tests palette mapping. + [Fact] + public void Jpeg2000Codec_Decode_Jp2Palette_MapsIndicesToColors() + { + var image = Img(12, 9, 1, 4, 1, 0); + int[][] palette = Enumerable.Range(0, 16).Select(i => new[] { i * 16, 255 - (i * 16), i * 8 }).ToArray(); + var jp2 = Jpeg2000TestEncoder.WrapJp2( + image, + Encode(image, Rev(1)), + new J2kJp2Options + { + Palette = palette, + PaletteDepths = [8, 8, 8], + Cmap = [(0, 1, 0), (0, 1, 1), (0, 1, 2)], + }); + var decoded = Jpeg2000Codec.Decode(jp2); + Assert.Equal(3, decoded.ColorChannelCount); + for (var i = 0; i < image.Width * image.Height; i++) + { + var e = palette[image.Components[0].Samples[i]]; + Assert.Equal(e[0], decoded.ColorSamples[i * 3]); + Assert.Equal(e[1], decoded.ColorSamples[(i * 3) + 1]); + Assert.Equal(e[2], decoded.ColorSamples[(i * 3) + 2]); + } + } + + /// Tests that BitDepth reports the source depth and HasPalette reports palette expansion. + [Fact] + public void Jpeg2000Codec_Decode_BitDepthAndHasPalette_ReportSourceFormat() + { + var plain = Img(6, 5, 1, 4, 1, 0); + var plainDecoded = Jpeg2000Codec.Decode(Jpeg2000TestEncoder.WrapJp2(plain, Encode(plain, Rev(1)), new J2kJp2Options { EnumCs = 17 })); + Assert.Equal(4, plainDecoded.BitDepth); + Assert.False(plainDecoded.HasPalette); + + var image = Img(6, 5, 1, 4, 1, 0); + int[][] palette = Enumerable.Range(0, 16).Select(i => new[] { i, i, i }).ToArray(); + var jp2 = Jpeg2000TestEncoder.WrapJp2( + image, + Encode(image, Rev(1)), + new J2kJp2Options { Palette = palette, PaletteDepths = [8, 8, 8], Cmap = [(0, 1, 0), (0, 1, 1), (0, 1, 2)] }); + var decoded = Jpeg2000Codec.Decode(jp2); + Assert.Equal(4, decoded.BitDepth); + Assert.True(decoded.HasPalette); + } + + /// Tests a mapping that mixes direct and palette channels, with a 16-bit palette column. + [Fact] + public void Jpeg2000Codec_Decode_Jp2PaletteMixedDirectChannel_Maps() + { + var image = Img(12, 9, 2, 4, 1, 0); + int[][] palette = Enumerable.Range(0, 16).Select(i => new[] { i * 4096, i * 16 }).ToArray(); + var jp2 = Jpeg2000TestEncoder.WrapJp2( + image, + Encode(image, Rev(1)), + new J2kJp2Options + { + Palette = palette, + PaletteDepths = [16, 8], + Cmap = [(1, 0, 0), (0, 1, 0), (0, 1, 1)], + }); + var decoded = Jpeg2000Codec.Decode(jp2); + Assert.Equal(3, decoded.ColorChannelCount); + var i0 = image.Components[0].Samples[0]; + Assert.Equal(Jpeg2000TestEncoder.ExpectedByte(image.Components[1].Samples[0], 4, false), decoded.ColorSamples[0]); + Assert.Equal((byte)Math.Min(255, ((palette[i0][0] * 255) + 32767) / 65535), decoded.ColorSamples[1]); + Assert.Equal(palette[i0][1], decoded.ColorSamples[2]); + } + + /// Tests an alpha channel declared by cdef. + [Theory] + [InlineData(1, false)] + [InlineData(2, true)] + public void Jpeg2000Codec_Decode_Jp2Cdef_ProducesAlpha(int type, bool premultiplied) + { + var image = Img(14, 11, 4); + var jp2 = Jpeg2000TestEncoder.WrapJp2( + image, + Encode(image, Rev(2)), + new J2kJp2Options { Cdef = [(0, 0, 1), (1, 0, 2), (2, 0, 3), (3, type, 0)] }); + var decoded = Jpeg2000Codec.Decode(jp2); + Assert.Equal(3, decoded.ColorChannelCount); + Assert.True(decoded.HasAlpha); + Assert.Equal(premultiplied, decoded.AlphaPremultiplied); + for (var i = 0; i < image.Width * image.Height; i++) + { + Assert.Equal(image.Components[3].Samples[i], decoded.AlphaSamples![i]); + } + } + + /// Tests that cdef can reorder color channels. + [Fact] + public void Jpeg2000Codec_Decode_Jp2CdefReorder_SwapsChannels() + { + var image = Img(9, 9, 3); + var jp2 = Jpeg2000TestEncoder.WrapJp2( + image, + Encode(image, Rev(1)), + new J2kJp2Options { Cdef = [(0, 0, 3), (1, 0, 2), (2, 0, 1)] }); + var decoded = Jpeg2000Codec.Decode(jp2); + for (var i = 0; i < 81; i++) + { + Assert.Equal(image.Components[0].Samples[i], decoded.ColorSamples[(i * 3) + 2]); + Assert.Equal(image.Components[2].Samples[i], decoded.ColorSamples[i * 3]); + } + } + + // ------------------------------------------------------------------------------------------ + // Public API + // ------------------------------------------------------------------------------------------ + + /// Tests that Load produces a surface with the decoded pixels. + [Fact] + public void Jpeg2000Codec_Load_Stream_ReturnsPixels() + { + var image = Img(13, 9, 3); + using var stream = new MemoryStream(Encode(image, Rev(2))); + using var surface = Jpeg2000Codec.Load(stream); + Assert.Equal(13, surface.Width); + Assert.Equal(9, surface.Height); + var p = surface.GetRowSpan(4)[5]; + Assert.Equal(Expected(image, 0, 5, 4), p.R); + Assert.Equal(Expected(image, 1, 5, 4), p.G); + Assert.Equal(Expected(image, 2, 5, 4), p.B); + Assert.Equal(255, p.A); + } + + /// Tests Load of a grayscale image. + [Fact] + public void Jpeg2000Codec_Load_Grey_ExpandsToRgb() + { + var image = Img(7, 7, 1); + using var surface = Jpeg2000Codec.Load(new MemoryStream(Encode(image, Rev(1)))); + var p = surface.GetRowSpan(2)[3]; + Assert.Equal(p.R, p.G); + Assert.Equal(p.G, p.B); + Assert.Equal(Expected(image, 0, 3, 2), p.R); + } + + /// Tests Load of an image with alpha. + [Fact] + public void Jpeg2000Codec_Load_Alpha_PopulatesAlpha() + { + var image = Img(7, 7, 4); + var jp2 = Jpeg2000TestEncoder.WrapJp2( + image, + Encode(image, Rev(1)), + new J2kJp2Options { Cdef = [(0, 0, 1), (1, 0, 2), (2, 0, 3), (3, 1, 0)] }); + using var surface = Jpeg2000Codec.Load(new MemoryStream(jp2)); + Assert.Equal(Expected(image, 3, 1, 1), surface.GetRowSpan(1)[1].A); + } + + /// Tests Load from a file path. + [Fact] + public void Jpeg2000Codec_Load_Path_ReturnsPixels() + { + var path = Path.Combine(Path.GetTempPath(), "j2k-" + Guid.NewGuid().ToString("N") + ".j2k"); + try + { + File.WriteAllBytes(path, Encode(Img(8, 8, 1), Rev(1))); + using var surface = Jpeg2000Codec.Load(path); + Assert.Equal(8, surface.Width); + Assert.Equal(8, Jpeg2000Codec.GetInfo(path).Height); + } + finally + { + File.Delete(path); + } + } + + /// Tests GetInfo. + [Fact] + public void Jpeg2000Codec_GetInfo_RawCodestream_ReportsDimensionsAndChannels() + { + var info = Jpeg2000Codec.GetInfo(new MemoryStream(Encode(Img(31, 17, 3), Rev(2)))); + Assert.Equal(31, info.Width); + Assert.Equal(17, info.Height); + Assert.Equal(3, info.Channels); + Assert.False(info.HasAlpha); + } + + /// Tests GetInfo on JP2 with alpha. + [Fact] + public void Jpeg2000Codec_GetInfo_Jp2WithAlpha_CountsAlphaChannel() + { + var image = Img(11, 12, 4); + var jp2 = Jpeg2000TestEncoder.WrapJp2( + image, + Encode(image, Rev(1)), + new J2kJp2Options { Cdef = [(0, 0, 1), (1, 0, 2), (2, 0, 3), (3, 1, 0)] }); + var info = Jpeg2000Codec.GetInfo(new MemoryStream(jp2)); + Assert.Equal(4, info.Channels); + Assert.True(info.HasAlpha); + } + + /// A stream wrapper that counts the bytes consumed (read or skipped) and can hide seekability. + private sealed class CountingStream(byte[] data, bool seekable) : Stream + { + private readonly MemoryStream _inner = new(data); + + public long BytesConsumed { get; private set; } + + /// Gets the largest single read requested, which bounds the buffer a caller allocates at once. + public int MaxReadRequest { get; private set; } + + public override bool CanRead => true; + + public override bool CanSeek => seekable; + + public override bool CanWrite => false; + + public override long Length => seekable ? _inner.Length : throw new NotSupportedException(); + + public override long Position + { + get => seekable ? _inner.Position : throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + MaxReadRequest = Math.Max(MaxReadRequest, count); + var n = _inner.Read(buffer, offset, count); + BytesConsumed += n; + return n; + } + + public override long Seek(long offset, SeekOrigin origin) + { + if (!seekable || origin != SeekOrigin.Current) + { + throw new NotSupportedException(); + } + + // Skipping is cheap on a seekable stream and does not count as reading the bytes. + return _inner.Seek(offset, origin); + } + + public override void Flush() + { + } + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + private const int ProbeTrailer = 4 * 1024 * 1024; + + /// Tests that GetInfo reads only a small header prefix of a raw codestream followed by a large payload. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Jpeg2000Codec_GetInfo_RawCodestream_ReadsOnlyHeaderPrefix(bool seekable) + { + var data = Encode(Img(31, 17, 3), Rev(2)).Concat(new byte[ProbeTrailer]).ToArray(); + using var stream = new CountingStream(data, seekable); + var info = Jpeg2000Codec.GetInfo(stream); + Assert.Equal(31, info.Width); + Assert.Equal(17, info.Height); + Assert.InRange(stream.BytesConsumed, 1, 70000); + } + + /// Tests that GetInfo reads only a small header prefix of a JP2 file followed by a large codestream. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Jpeg2000Codec_GetInfo_Jp2_ReadsOnlyHeaderPrefix(bool seekable) + { + var image = Img(11, 12, 4); + var jp2 = Jpeg2000TestEncoder.WrapJp2( + image, + Encode(image, Rev(1)).Concat(new byte[ProbeTrailer]).ToArray(), + new J2kJp2Options { Cdef = [(0, 0, 1), (1, 0, 2), (2, 0, 3), (3, 1, 0)] }); + using var stream = new CountingStream(jp2, seekable); + var info = Jpeg2000Codec.GetInfo(stream); + Assert.Equal(4, info.Channels); + Assert.True(info.HasAlpha); + Assert.InRange(stream.BytesConsumed, 1, 70000); + } + + /// Tests that a large box before the codestream is skipped by seeking when possible and discarded otherwise. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Jpeg2000Codec_GetInfo_LargeSkippedBox_SeekOrDiscard(bool seekable) + { + var image = Img(11, 12, 3); + var jp2 = Jpeg2000TestEncoder.WrapJp2(image, Encode(image, Rev(1)), new J2kJp2Options()); + var box = new byte[8 + ProbeTrailer]; + box[0] = (byte)((box.Length >> 24) & 0xFF); + box[1] = (byte)((box.Length >> 16) & 0xFF); + box[2] = (byte)((box.Length >> 8) & 0xFF); + box[3] = (byte)(box.Length & 0xFF); + "free"u8.CopyTo(box.AsSpan(4)); + var ftypEnd = 12 + (int)BinaryPrimitives.ReadUInt32BigEndian(jp2.AsSpan(12)); + var data = jp2[..ftypEnd].Concat(box).Concat(jp2[ftypEnd..]).ToArray(); + using var stream = new CountingStream(data, seekable); + Assert.Equal(11, Jpeg2000Codec.GetInfo(stream).Width); + if (seekable) + { + Assert.InRange(stream.BytesConsumed, 1, 70000); + } + } + + /// Tests that a seekable truncated JP2 box and a truncated non-seekable skipped box are both rejected as invalid. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Jpeg2000Codec_GetInfo_TruncatedBox_ThrowsInvalidData(bool seekable) + { + var image = Img(11, 12, 3); + var jp2 = Jpeg2000TestEncoder.WrapJp2(image, Encode(image, Rev(1)), new J2kJp2Options()); + var data = jp2[..20]; + using var stream = new CountingStream(data, seekable); + Assert.Throws(() => Jpeg2000Codec.GetInfo(stream)); + } + + /// Returns a copy of a JP2 file with a box inserted at the start of its jp2h content. + private static byte[] InsertIntoJp2h(byte[] jp2, byte[] box, uint? declaredJp2hLength = null) + { + var jp2hStart = 12 + (int)BinaryPrimitives.ReadUInt32BigEndian(jp2.AsSpan(12)); + var jp2hLength = BinaryPrimitives.ReadUInt32BigEndian(jp2.AsSpan(jp2hStart)); + var result = jp2[..(jp2hStart + 8)].Concat(box).Concat(jp2[(jp2hStart + 8)..]).ToArray(); + BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(jp2hStart), declaredJp2hLength ?? (jp2hLength + (uint)box.Length)); + return result; + } + + /// Builds a free box of the given total length. + private static byte[] FreeBox(int length) + { + var box = new byte[length]; + BinaryPrimitives.WriteUInt32BigEndian(box, (uint)length); + "free"u8.CopyTo(box.AsSpan(4)); + return box; + } + + /// Tests that a large ICC profile in colr is never buffered by GetInfo and the result matches the small-profile case. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Jpeg2000Codec_GetInfo_LargeIccProfile_IsNotBuffered(bool seekable) + { + var image = Img(11, 12, 3); + var codestream = Encode(image, Rev(1)); + var small = Jpeg2000TestEncoder.WrapJp2(image, codestream, new J2kJp2Options { Icc = IccHeader(64) }); + var large = Jpeg2000TestEncoder.WrapJp2(image, codestream, new J2kJp2Options { Icc = IccHeader(ProbeTrailer) }); + var expected = Jpeg2000Codec.GetInfo(new MemoryStream(small)); + using var stream = new CountingStream(large, seekable); + Assert.Equal(expected, Jpeg2000Codec.GetInfo(stream)); + Assert.True(stream.MaxReadRequest <= 70000); + if (seekable) + { + Assert.InRange(stream.BytesConsumed, 1, 70000); + } + } + + private static byte[] IccHeader(int length) + { + var icc = new byte[length]; + "RGB "u8.CopyTo(icc.AsSpan(16)); + return icc; + } + + /// Tests that a large unrelated box inside jp2h is skipped, not buffered, and gives the same result. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Jpeg2000Codec_GetInfo_LargeBoxInsideJp2h_IsNotBuffered(bool seekable) + { + var image = Img(11, 12, 4); + var jp2 = Jpeg2000TestEncoder.WrapJp2( + image, + Encode(image, Rev(1)), + new J2kJp2Options { Cdef = [(0, 0, 1), (1, 0, 2), (2, 0, 3), (3, 1, 0)] }); + var expected = Jpeg2000Codec.GetInfo(new MemoryStream(jp2)); + using var stream = new CountingStream(InsertIntoJp2h(jp2, FreeBox(ProbeTrailer)), seekable); + Assert.Equal(expected, Jpeg2000Codec.GetInfo(stream)); + Assert.True(stream.MaxReadRequest <= 70000); + if (seekable) + { + Assert.InRange(stream.BytesConsumed, 1, 70000); + } + } + + /// Tests that a hostile jp2h length far beyond the data is rejected without reading or allocating it. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Jpeg2000Codec_GetInfo_HostileJp2hLength_ThrowsInvalidDataWithBoundedReads(bool seekable) + { + var image = Img(11, 12, 3); + var jp2 = Jpeg2000TestEncoder.WrapJp2(image, Encode(image, Rev(1)), new J2kJp2Options()); + var hostile = InsertIntoJp2h(jp2, [], 200u * 1024 * 1024); + using var stream = new CountingStream(hostile, seekable); + Assert.Throws(() => Jpeg2000Codec.GetInfo(stream)); + Assert.True(stream.MaxReadRequest <= 70000); + Assert.True(stream.BytesConsumed <= hostile.Length); + } + + /// Tests that an oversized palette box inside jp2h is rejected rather than buffered. + [Fact] + public void Jpeg2000Codec_GetInfo_OversizedPaletteBox_ThrowsInvalidData() + { + var image = Img(11, 12, 3); + var jp2 = Jpeg2000TestEncoder.WrapJp2(image, Encode(image, Rev(1)), new J2kJp2Options()); + var box = FreeBox(2 * 1024 * 1024); + "pclr"u8.CopyTo(box.AsSpan(4)); + using var stream = new CountingStream(InsertIntoJp2h(jp2, box), true); + Assert.Throws(() => Jpeg2000Codec.GetInfo(stream)); + Assert.InRange(stream.BytesConsumed, 1, 70000); + } + + /// Tests that a component-mapping box without a palette box is ignored (ISO 15444-1 I.5.3.5; as OpenJPEG). + [Fact] + public void Jpeg2000Codec_Decode_CmapWithoutPclr_IsIgnored() + { + var image = Img(10, 10, 3); + var codestream = Encode(image, Rev(1)); + var plain = Jpeg2000Codec.Decode(Jpeg2000TestEncoder.WrapJp2(image, codestream, new J2kJp2Options { EnumCs = 16 })); + var withCmap = Jpeg2000Codec.Decode( + Jpeg2000TestEncoder.WrapJp2(image, codestream, new J2kJp2Options { EnumCs = 16, Cmap = [(2, 0, 0), (1, 0, 0), (0, 0, 0)] })); + Assert.Equal(3, withCmap.ColorChannelCount); + Assert.Equal(plain.ColorSamples, withCmap.ColorSamples); + } + + /// Tests that a seekable stream above the input cap is rejected by GetInfo as before. + [Fact] + public void Jpeg2000Codec_GetInfo_SeekableAboveInputCap_ThrowsInvalidData() + { + using var stream = new SparseStream(Jpeg2000DecoderLimits.Default.MaxInputBytes + 1); + Assert.Throws(() => Jpeg2000Codec.GetInfo(stream)); + } + + /// A seekable zero-filled stream of a declared length that allocates nothing. + private sealed class SparseStream(long length) : Stream + { + public override bool CanRead => true; + + public override bool CanSeek => true; + + public override bool CanWrite => false; + + public override long Length => length; + + public override long Position { get; set; } + + public override int Read(byte[] buffer, int offset, int count) => 0; + + public override long Seek(long offset, SeekOrigin origin) => Position = offset; + + public override void Flush() + { + } + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + /// Tests the image offset is reflected in GetInfo dimensions. + [Fact] + public void Jpeg2000Codec_GetInfo_ImageOffset_ReportsWidthMinusOffset() + { + var image = Img(20, 10, 1); + image.XOsiz = 5; + image.Xsiz += 5; + var info = Jpeg2000Codec.GetInfo(new MemoryStream(Encode(image, Rev(1)))); + Assert.Equal(20, info.Width); + } + + /// Tests null and empty arguments. + [Fact] + public void Jpeg2000Codec_NullOrEmptyArguments_Throw() + { + Assert.Throws(() => Jpeg2000Codec.Load((Stream)null!)); + Assert.Throws(() => Jpeg2000Codec.Load((string)null!)); + Assert.Throws(() => Jpeg2000Codec.Load(string.Empty)); + Assert.Throws(() => Jpeg2000Codec.GetInfo((Stream)null!)); + Assert.Throws(() => Jpeg2000Codec.GetInfo((string)null!)); + Assert.Throws(() => Jpeg2000Codec.Decode((byte[])null!)); + Assert.Throws(() => Jpeg2000Codec.Decode((Stream)null!)); + } + + /// Tests a missing file. + [Fact] + public void Jpeg2000Codec_Load_MissingFile_ThrowsFileNotFound() => + Assert.Throws(() => Jpeg2000Codec.Load(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".jp2"))); + + /// Tests data that is not JPEG 2000. + [Theory] + [InlineData(new byte[0])] + [InlineData(new byte[] { 1 })] + [InlineData(new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0, 0 })] + [InlineData(new byte[] { 0xFF, 0x4F })] + [InlineData(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0 })] + public void Jpeg2000Codec_Load_NotJpeg2000_ThrowsInvalidData(byte[] data) + { + Assert.Throws(() => Jpeg2000Codec.Load(new MemoryStream(data))); + Assert.Throws(() => Jpeg2000Codec.GetInfo(new MemoryStream(data))); + } + + // ------------------------------------------------------------------------------------------ + // Unsupported features + // ------------------------------------------------------------------------------------------ + + private static byte[] Patch(byte[] data, int offset, params byte[] bytes) + { + var copy = (byte[])data.Clone(); + Array.Copy(bytes, 0, copy, offset, bytes.Length); + return copy; + } + + /// Patches the image size and the tile size of the SIZ marker segment. + private static byte[] Resize(byte[] data, int width, int height, int tileWidth, int tileHeight) + { + var siz = FindMarker(data, 0xFF51); + var copy = (byte[])data.Clone(); + foreach (var (offset, value) in new[] { (6, width), (10, height), (22, tileWidth), (26, tileHeight) }) + { + copy[siz + offset] = (byte)(value >> 24); + copy[siz + offset + 1] = (byte)(value >> 16); + copy[siz + offset + 2] = (byte)(value >> 8); + copy[siz + offset + 3] = (byte)value; + } + + return copy; + } + + private static int FindMarker(byte[] data, int marker) + { + for (var i = 2; i < data.Length - 1; i++) + { + if (data[i] == (marker >> 8) && data[i + 1] == (marker & 0xFF)) + { + return i; + } + } + + return -1; + } + + /// Tests that Part 2 extensions are rejected as unsupported. + [Fact] + public void Jpeg2000Codec_Decode_Part2Capabilities_ThrowsUnsupported() + { + var data = Encode(Img(8, 8, 1), Rev(1)); + var siz = FindMarker(data, 0xFF51); + Assert.Throws(() => Jpeg2000Codec.Decode(Patch(data, siz + 4, 0x80, 0x00))); + } + + /// Tests that high-throughput JPEG 2000 is rejected as unsupported. + [Fact] + public void Jpeg2000Codec_Decode_HighThroughput_ThrowsUnsupported() + { + var data = Encode(Img(8, 8, 1), Rev(1)); + var cod = FindMarker(data, 0xFF52); + Assert.Throws(() => Jpeg2000Codec.Decode(Patch(data, cod + 12, 0x40))); + Assert.Throws(() => Jpeg2000Codec.Decode(Patch(data, cod + 12, 0x40 | 0x80))); + } + + /// Tests that unknown wavelet transforms and component transforms are rejected as unsupported. + [Fact] + public void Jpeg2000Codec_Decode_UnknownTransforms_ThrowsUnsupported() + { + var data = Encode(Img(8, 8, 3), Rev(1)); + var cod = FindMarker(data, 0xFF52); + Assert.Throws(() => Jpeg2000Codec.Decode(Patch(data, cod + 13, 5))); + Assert.Throws(() => Jpeg2000Codec.Decode(Patch(data, cod + 8, 3))); + } + + /// Tests that an unsupported ROI style is rejected. + [Fact] + public void Jpeg2000Codec_Decode_UnknownRoiStyle_ThrowsUnsupported() + { + var o = Rev(1); + o.RoiShift = 13; + var data = Encode(Img(8, 8, 1), o); + var rgn = FindMarker(data, 0xFF5E); + Assert.Throws(() => Jpeg2000Codec.Decode(Patch(data, rgn + 5, 1))); + } + + /// Tests that more than sixteen components are rejected as unsupported. + [Fact] + public void Jpeg2000Codec_Decode_TooManyComponents_ThrowsUnsupported() + { + var data = Encode(Img(4, 4, 17), Rev(1)); + var ex = Assert.Throws(() => Jpeg2000Codec.Decode(data)); + Assert.Equal("jpeg2000-component-count", ex.Feature); + } + + /// Tests that a two-component codestream without a channel definition decodes as grey. + [Fact] + public void Jpeg2000Codec_Decode_TwoComponentsWithoutCdef_DecodesAsGrey() => + Assert.Equal(1, Jpeg2000Codec.Decode(Encode(Img(8, 8, 2), Rev(1))).ColorChannelCount); + + /// Tests that five channels without a channel definition are rejected as unsupported. + [Fact] + public void Jpeg2000Codec_Decode_FiveComponentsWithoutCdef_ThrowsUnsupported() => + Assert.Throws(() => Jpeg2000Codec.Decode(Encode(Img(8, 8, 5), Rev(1)))); + + /// Tests that depths above sixteen bits are rejected as unsupported. + [Fact] + public void Jpeg2000Codec_Decode_DepthAbove16_ThrowsUnsupported() + { + var data = Encode(Img(8, 8, 1), Rev(1)); + var siz = FindMarker(data, 0xFF51); + Assert.Throws(() => Jpeg2000Codec.Decode(Patch(data, siz + 40, 0x10))); + } + + /// Tests that images wider than the surface limit are rejected by Load but reported by GetInfo. + [Fact] + public void Jpeg2000Codec_Load_ExceedsMaxDimension_ThrowsInvalidData() + { + var data = Encode(Img(8, 8, 1), Rev(1)); + var siz = FindMarker(data, 0xFF51); + var huge = Patch(data, siz + 6, 0x00, 0x01, 0x00, 0x00); + Assert.Throws(() => Jpeg2000Codec.Load(new MemoryStream(huge))); + Assert.Equal(65536, Jpeg2000Codec.GetInfo(new MemoryStream(huge)).Width); + } + + // ------------------------------------------------------------------------------------------ + // Malformed, truncated and hostile input + // ------------------------------------------------------------------------------------------ + + private static IEnumerable Fixtures() + { + var multi = Rev(3); + multi.Layers = 3; + multi.TileWidth = 24; + multi.TileHeight = 24; + multi.Progression = 2; + multi.Precincts = [(4, 4), (5, 5)]; + multi.Sop = true; + multi.Eph = true; + multi.CodeBlockStyle = 0x3F & ~0x10; + multi.Mct = true; + yield return Encode(Img(40, 30, 3), multi); + + var ppm = Rev(2); + ppm.HeaderMode = J2kHeaderMode.Ppm; + ppm.TileWidth = 20; + ppm.TileHeight = 20; + ppm.TileParts = 2; + ppm.Poc = TestPoc(1, 1); + yield return Encode(Img(30, 30, 1), ppm); + + var irr = Irrev(3); + irr.Mct = true; + irr.RoiShift = 14; + yield return Jpeg2000TestEncoder.WrapJp2( + Img(24, 20, 3), + Encode(Img(24, 20, 3), irr), + new J2kJp2Options { WriteBpcc = true }); + + var ppt = Rev(2); + ppt.HeaderMode = J2kHeaderMode.Ppt; + ppt.ExtraMarkers = true; + ppt.Layers = 2; + yield return Encode(Img(25, 19, 1), ppt); + } + + /// Tests that every possible truncation of a small stream fails closed with InvalidDataException. + [Fact] + public void Jpeg2000Codec_Decode_EveryTruncationOfSmallStream_ThrowsInvalidData() + { + var o = Rev(2); + o.Layers = 2; + o.TileWidth = 16; + o.TileHeight = 16; + o.Sop = true; + o.Eph = true; + var stream = Encode(Img(20, 12, 1), o); + + // Every prefix that loses more than the EOC marker is rejected. + for (var len = 0; len < stream.Length - 2; len++) + { + var prefix = stream[..len]; + var ex = Assert.Throws(() => Jpeg2000Codec.Decode(prefix)); + Assert.Null(ex.InnerException); + } + + _ = Jpeg2000Codec.Decode(stream); + } + + /// Tests that truncations of larger generated streams are always rejected (no partial decoding). + [Fact] + public void Jpeg2000Codec_Decode_TruncationsOfLargeStreams_ThrowsInvalidData() + { + foreach (var fixture in Fixtures()) + { + var step = Math.Max(1, fixture.Length / 400); + for (var len = 0; len < fixture.Length - 16; len += step) + { + var prefix = fixture[..len]; + var ex = Assert.Throws(() => Jpeg2000Codec.Decode(prefix)); + Assert.Null(ex.InnerException); + } + } + } + + /// Tests that bit flips fail cleanly or decode, with the work bounded by explicit decoder limits. + [Fact] + public void Jpeg2000Codec_Decode_RandomBitFlips_FailCleanly() + { + var rng = new Random(1234); + foreach (var fixture in Fixtures()) + { + for (var i = 0; i < 400; i++) + { + var copy = (byte[])fixture.Clone(); + var flips = rng.Next(1, 4); + for (var f = 0; f < flips; f++) + { + // Concentrate on the headers, where structural fields live. + var pos = rng.Next(3) == 0 ? rng.Next(copy.Length) : rng.Next(Math.Min(copy.Length, 200)); + copy[pos] ^= (byte)(1 << rng.Next(8)); + } + + AssertFuzzOutcome(copy); + } + } + } + + /// Tests that every single byte of the main header can be replaced by extreme values. + [Fact] + public void Jpeg2000Codec_Decode_HeaderByteSubstitutions_FailCleanly() + { + foreach (var fixture in Fixtures()) + { + var sot = FindMarker(fixture, 0xFF90); + var limit = sot > 0 ? Math.Min(sot + 14, fixture.Length) : Math.Min(300, fixture.Length); + for (var pos = 0; pos < limit; pos++) + { + foreach (var value in new byte[] { 0x00, 0x01, 0x7F, 0x80, 0xFF }) + { + var copy = (byte[])fixture.Clone(); + copy[pos] = value; + AssertFuzzOutcome(copy); + } + } + } + } + + /// Tests marker segments with bad lengths. + [Fact] + public void Jpeg2000Codec_Decode_BadMarkerLengths_ThrowsInvalidData() + { + var data = Encode(Img(16, 16, 3), Rev(2)); + foreach (var marker in new[] { 0xFF51, 0xFF52, 0xFF5C }) + { + var pos = FindMarker(data, marker); + foreach (var len in new[] { 0x0000, 0x0001, 0x0002, 0x0003, 0x0100, 0xFFFF }) + { + var broken = Patch(data, pos + 2, (byte)(len >> 8), (byte)len); + var ex = Assert.Throws(() => Jpeg2000Codec.Decode(broken)); + Assert.StartsWith("Invalid JPEG 2000 data: ", ex.Message); + Assert.Null(ex.InnerException); + } + } + } + + /// Tests tile-part lengths that are inconsistent with the data. + [Fact] + public void Jpeg2000Codec_Decode_BadTilePartLengths_ThrowsInvalidData() + { + var data = Encode(Img(16, 16, 3), Rev(2)); + var sot = FindMarker(data, 0xFF90); + foreach (var psot in new[] { 1u, 11u, 13u, 0xFFFFFFFFu, 100000u }) + { + var broken = Patch(data, sot + 6, (byte)(psot >> 24), (byte)(psot >> 16), (byte)(psot >> 8), (byte)psot); + var ex = Assert.Throws(() => Jpeg2000Codec.Decode(broken)); + Assert.StartsWith("Invalid JPEG 2000 data: ", ex.Message); + Assert.Null(ex.InnerException); + } + + // A zero length is legal for the last tile-part (it extends to the end of the data). + _ = Jpeg2000Codec.Decode(Patch(data, sot + 6, 0, 0, 0, 0)); + } + + /// Tests that tile-parts of a tile must come in order (TPsot) while the advisory count (TNsot) is not enforced. + [Fact] + public void Jpeg2000Codec_Decode_TilePartIndexes_AreCheckedAndCountIsAdvisory() + { + var o = Rev(2); + o.TileParts = 2; + var data = Encode(Img(16, 16, 1), o); + var first = FindMarker(data, 0xFF90); + var second = first + 2; + while (!(data[second] == 0xFF && data[second + 1] == 0x90)) + { + second++; + } + + // Layout of the SOT marker segment: marker, Lsot, Isot, Psot, TPsot (offset 10), TNsot (offset 11). + AssertMalformed(Patch(Patch(data, first + 10, 1), second + 10, 0), "tile-part index is out of sequence"); + AssertMalformed(Patch(data, second + 10, 0), "tile-part index is out of sequence"); + AssertMalformed(Patch(data, first + 10, 1), "tile-part index is out of sequence"); + + // The count is advisory: zero (unknown) and a wrong count decode to the same image. + var expected = Jpeg2000Codec.Decode(data).ColorSamples; + foreach (var count in new byte[] { 0, 1, 9 }) + { + var counted = Patch(Patch(data, first + 11, count), second + 11, count); + Assert.Equal(expected, Jpeg2000Codec.Decode(counted).ColorSamples); + } + } + + /// Tests that COD, COC, QCD, QCC and RGN are rejected in a tile-part after the first (ISO 15444-1 A.6). + [Fact] + public void Jpeg2000Codec_Decode_ParamMarkersInLaterTilePart_ThrowsInvalidData() + { + var baseline = Rev(2); + baseline.TileParts = 2; + var plain = Encode(Img(16, 16, 1), baseline); + var cod = MainSegment(plain, 0xFF52); + var qcd = MainSegment(plain, 0xFF5C); + var coc = Segment(0xFF53, [0, 0, .. cod.Skip(4 + 5)]); + var qcc = Segment(0xFF5D, [0, .. qcd.Skip(4)]); + var rgn = Segment(0xFF5E, [0, 0, 1]); + foreach (var later in new[] { cod, qcd, coc, qcc, rgn }) + { + var o = Rev(2); + o.TileParts = 2; + o.LaterPartSegments = later; + AssertMalformed(Encode(Img(16, 16, 1), o), "first tile-part header"); + } + + // The same markers in the first tile-part remain valid; the restriction is per tile, not global. + var tiled = Rev(2); + tiled.TileParts = 2; + tiled.TileWidth = 8; + tiled.TileHeight = 16; + var tiledData = Encode(Img(16, 16, 1), tiled); + Assert.NotNull(Jpeg2000Codec.Decode(tiledData)); + tiled.LaterPartSegments = rgn; + AssertMalformed(Encode(Img(16, 16, 1), tiled), "first tile-part header"); + } + + /// Tests that POC in a later tile-part extends the progression list and still decodes. + [Fact] + public void Jpeg2000Codec_Decode_PocInLaterTilePart_RoundTripsExactly() + { + var o = Rev(3); + o.Layers = 3; + o.Poc = TestPoc(3, 3); + o.PocInTileHeader = true; + o.PocSplitAcrossParts = true; + o.TileParts = 2; + AssertExact(Img(40, 40, 3), o); + } + + private static byte[] Segment(int marker, IEnumerable payload) + { + var body = payload.ToArray(); + return [(byte)(marker >> 8), (byte)marker, (byte)((body.Length + 2) >> 8), (byte)(body.Length + 2), .. body]; + } + + private static byte[] MainSegment(byte[] data, int marker) + { + var at = FindMarker(data, marker); + var len = (data[at + 2] << 8) | data[at + 3]; + return data[at..(at + 2 + len)]; + } + + /// Tests a stream that is only a header, or whose tile data are garbage. + [Fact] + public void Jpeg2000Codec_Decode_GarbageTileData_FailsCleanly() + { + var data = Encode(Img(24, 24, 3), Rev(2)); + var sod = FindMarker(data, 0xFF93) + 2; + var rng = new Random(77); + for (var i = 0; i < 100; i++) + { + var copy = (byte[])data.Clone(); + for (var j = sod; j < copy.Length - 2; j++) + { + copy[j] = (byte)rng.Next(256); + } + + AssertFuzzOutcome(copy); + } + } + + /// Appends one byte to the first marker segment of the given type and fixes its length field. + private static byte[] GrowSegment(byte[] data, int marker) + { + var at = FindMarker(data, marker); + var len = (data[at + 2] << 8) | data[at + 3]; + var grown = new byte[data.Length + 1]; + Array.Copy(data, 0, grown, 0, at + 2 + len); + grown[at + 2 + len] = 0; + Array.Copy(data, at + 2 + len, grown, at + 3 + len, data.Length - at - 2 - len); + grown[at + 2] = (byte)((len + 1) >> 8); + grown[at + 3] = (byte)(len + 1); + return grown; + } + + /// Removes the last payload byte of the first marker segment of the given type and fixes its length field. + private static byte[] ShrinkSegment(byte[] data, int marker) + { + var at = FindMarker(data, marker); + var len = (data[at + 2] << 8) | data[at + 3]; + var shrunk = new byte[data.Length - 1]; + Array.Copy(data, 0, shrunk, 0, at + 1 + len); + Array.Copy(data, at + 2 + len, shrunk, at + 1 + len, data.Length - at - 2 - len); + shrunk[at + 2] = (byte)((len - 1) >> 8); + shrunk[at + 3] = (byte)(len - 1); + return shrunk; + } + + /// Appends one byte to the named JP2 header sub-box (and its jp2h parent) and fixes both lengths. + private static byte[] GrowJp2Box(byte[] jp2, string type) + { + var typeBytes = System.Text.Encoding.ASCII.GetBytes(type); + int Find(string t, int from) + { + var tb = System.Text.Encoding.ASCII.GetBytes(t); + for (var i = from; i < jp2.Length - 4; i++) + { + if (jp2.AsSpan(i, 4).SequenceEqual(tb)) + { + return i - 4; + } + } + + return -1; + } + + var parent = Find("jp2h", 0); + var box = Find(System.Text.Encoding.ASCII.GetString(typeBytes), parent + 8); + int Length(int at) => (jp2[at] << 24) | (jp2[at + 1] << 16) | (jp2[at + 2] << 8) | jp2[at + 3]; + var boxLen = Length(box); + var parentLen = Length(parent); + var grown = new byte[jp2.Length + 1]; + Array.Copy(jp2, 0, grown, 0, box + boxLen); + Array.Copy(jp2, box + boxLen, grown, box + boxLen + 1, jp2.Length - box - boxLen); + foreach (var (at, len) in new[] { (box, boxLen + 1), (parent, parentLen + 1) }) + { + grown[at] = (byte)(len >> 24); + grown[at + 1] = (byte)(len >> 16); + grown[at + 2] = (byte)(len >> 8); + grown[at + 3] = (byte)len; + } + + return grown; + } + + /// Tests that an odd-length expounded QCD payload is rejected instead of silently dropping a byte. + [Fact] + public void Jpeg2000Codec_Decode_OddLengthExpoundedQcd_ThrowsInvalidData() + { + var data = Encode(Img(16, 16, 1), Irrev(2)); + Assert.Equal(2, data[FindMarker(data, 0xFF5C) + 4] & 0x1F); + Assert.Throws(() => Jpeg2000Codec.Decode(GrowSegment(data, 0xFF5C))); + } + + /// Tests that an odd-length expounded QCC payload is rejected instead of silently dropping a byte. + [Fact] + public void Jpeg2000Codec_Decode_OddLengthExpoundedQcc_ThrowsInvalidData() + { + var o = Irrev(2); + o.ComponentLevels = [2, 1, 2]; + var data = Encode(Img(16, 16, 3), o); + Assert.True(FindMarker(data, 0xFF5D) > 0); + Assert.Throws(() => Jpeg2000Codec.Decode(GrowSegment(data, 0xFF5D))); + } + + /// Tests that a derived-style QCD with a trailing byte is rejected. + [Fact] + public void Jpeg2000Codec_Decode_DerivedQcdTrailingByte_ThrowsInvalidData() + { + var o = Irrev(2); + o.QuantStyle = 1; + var data = Encode(Img(16, 16, 1), o); + Assert.Throws(() => Jpeg2000Codec.Decode(GrowSegment(data, 0xFF5C))); + } + + /// Tests that SIZ, COD, RGN and COC segments with trailing or missing bytes are rejected. + [Fact] + public void Jpeg2000Codec_Decode_CodestreamSegmentLengthMismatch_ThrowsInvalidData() + { + var o = Rev(2); + o.RoiShift = 3; + var plain = Encode(Img(16, 16, 1), o); + foreach (var marker in new[] { 0xFF51, 0xFF52, 0xFF5E }) + { + Assert.Throws(() => Jpeg2000Codec.Decode(GrowSegment(plain, marker))); + Assert.Throws(() => Jpeg2000Codec.Decode(ShrinkSegment(plain, marker))); + } + + var perComponent = Rev(2); + perComponent.ComponentLevels = [2, 1, 2]; + var coc = Encode(Img(16, 16, 3), perComponent); + Assert.Throws(() => Jpeg2000Codec.Decode(GrowSegment(coc, 0xFF53))); + } + + /// Tests that an SOP marker segment with a length other than 4 is rejected. + [Fact] + public void Jpeg2000Codec_Decode_SopWrongLength_ThrowsInvalidData() + { + var o = Rev(1); + o.Sop = true; + var data = Encode(Img(8, 8, 1), o); + var sop = FindMarker(data, 0xFF91); + Assert.Throws(() => Jpeg2000Codec.Decode(Patch(data, sop + 3, 6))); + } + + /// Tests that JP2 colr, pclr and cdef boxes with trailing bytes are rejected. + [Fact] + public void Jpeg2000Codec_Decode_Jp2BoxLengthMismatch_ThrowsInvalidData() + { + var gray = Img(10, 10, 1); + var colr = Jpeg2000TestEncoder.WrapJp2(gray, Encode(gray, Rev(1)), new J2kJp2Options { EnumCs = 17 }); + Assert.Throws(() => Jpeg2000Codec.Decode(GrowJp2Box(colr, "colr"))); + + var rgb = Img(10, 10, 3); + var cdef = Jpeg2000TestEncoder.WrapJp2(rgb, Encode(rgb, Rev(1)), new J2kJp2Options { Cdef = [(0, 0, 1), (1, 0, 2), (2, 0, 3)] }); + Assert.Throws(() => Jpeg2000Codec.Decode(GrowJp2Box(cdef, "cdef"))); + + var indexed = Img(12, 9, 1, 4, 1, 0); + int[][] palette = Enumerable.Range(0, 16).Select(i => new[] { i }).ToArray(); + var pclr = Jpeg2000TestEncoder.WrapJp2( + indexed, + Encode(indexed, Rev(1)), + new J2kJp2Options { Palette = palette, PaletteDepths = [8], Cmap = [(0, 1, 0)] }); + Assert.Throws(() => Jpeg2000Codec.Decode(GrowJp2Box(pclr, "pclr"))); + } + + /// Tests hostile JP2 box structures. + [Fact] + public void Jpeg2000Codec_Decode_HostileBoxes_FailCleanly() + { + var image = Img(10, 10, 3); + var jp2 = Jpeg2000TestEncoder.WrapJp2(image, Encode(image, Rev(1)), new J2kJp2Options { Cdef = [(0, 0, 1), (1, 0, 2), (2, 0, 3)] }); + var rng = new Random(5); + for (var i = 0; i < 600; i++) + { + var copy = (byte[])jp2.Clone(); + copy[rng.Next(Math.Min(copy.Length, 220))] = (byte)rng.Next(256); + AssertFuzzOutcome(copy); + } + + foreach (var len in new byte[][] { [0, 0, 0, 1], [0, 0, 0, 2], [0xFF, 0xFF, 0xFF, 0xFF], [0, 0, 0, 7] }) + { + AssertFuzzOutcome(Patch(jp2, 0, len)); + AssertFuzzOutcome(Patch(jp2, 12, len)); + AssertFuzzOutcome(Patch(jp2, 32, len)); + } + } + + /// Tests a progression-order-change marker that loops or has out-of-range indexes. + [Fact] + public void Jpeg2000Codec_Decode_HostilePoc_FailsCleanly() + { + var o = Rev(2); + o.Layers = 2; + o.Poc = [new J2kPoc(0, 0, 2, 3, 1, 0)]; + var data = Encode(Img(16, 16, 1), o); + var poc = FindMarker(data, 0xFF5F); + for (var i = 4; i < 13; i++) + { + foreach (var v in new byte[] { 0, 1, 0x7F, 0xFF }) + { + AssertFuzzOutcome(Patch(data, poc + i, v)); + } + } + } + + // ------------------------------------------------------------------------------------------ + // Resource limits and hardening + // ------------------------------------------------------------------------------------------ + + /// Tests that a stream declaring more progression changes than the cap is rejected when the marker is parsed. + [Fact] + public void Jpeg2000Codec_Decode_MoreProgressionChangesThanCap_ThrowsInvalidData() + { + var o = Rev(3); + o.Layers = 4; + o.Poc = [.. Enumerable.Repeat(new J2kPoc(0, 0, 4, 4, 3, 0), 5000)]; + AssertMalformed(Encode(Img(32, 32, 3), o), "too many progression order changes"); + + // A tighter custom cap applies as well, and the default cap is exactly reached by 128 entries. + o.Poc = [.. Enumerable.Repeat(new J2kPoc(0, 0, 4, 4, 3, 0), 5)]; + AssertMalformed(Encode(Img(32, 32, 3), o), "too many progression order changes", new Jpeg2000DecoderLimits { MaxProgressionChanges = 4 }); + } + + /// Tests that progression volumes leaving packets uncovered are rejected instead of decoding a partial image. + [Fact] + public void Jpeg2000Codec_Decode_PocLeavingPacketsUncovered_ThrowsInvalidData() + { + const string cause = "the progression order does not cover every packet of the tile"; + + // Only the first layer is covered. + var layers = Rev(2); + layers.Layers = 2; + layers.Poc = [new J2kPoc(0, 0, 1, 3, 1, 0)]; + AssertMalformed(Encode(Img(16, 16, 1), layers), cause); + + // Only the first two of three components are covered, and the same applies to a tile-part POC with tiles. + var comps = Rev(2); + comps.Poc = [new J2kPoc(0, 0, 1, 3, 2, 1)]; + AssertMalformed(Encode(Img(16, 16, 3), comps), cause); + comps.PocInTileHeader = true; + comps.TileWidth = 8; + comps.TileHeight = 8; + AssertMalformed(Encode(Img(16, 16, 3), comps), cause); + + // Complementary volumes together cover everything and decode. + var full = Rev(2); + full.Layers = 2; + full.Poc = [new J2kPoc(0, 0, 1, 3, 1, 0), new J2kPoc(0, 0, 2, 3, 1, 1)]; + AssertExact(Img(16, 16, 1), full); + } + + /// Tests that a later POC volume with a larger layer end skips already-sent packets and reads only the new layers. + [Theory] + [InlineData(0, 0)] + [InlineData(0, 1)] + [InlineData(2, 4)] + public void Jpeg2000Codec_Decode_PocLaterVolumeLargerLayerEnd_SkipsSentPackets(int firstOrder, int secondOrder) + { + var o = Rev(2); + o.Layers = 4; + o.Poc = [new J2kPoc(0, 0, 2, 3, 3, firstOrder), new J2kPoc(0, 0, 4, 3, 3, secondOrder)]; + AssertExact(Img(32, 32, 3), o); + } + + /// Tests that a later POC volume with a larger layer end skips already-sent packets in a tile-part POC. + [Fact] + public void Jpeg2000Codec_Decode_PocTileHeaderLaterVolumeLargerLayerEnd_SkipsSentPackets() + { + var o = Rev(2); + o.Layers = 4; + o.Poc = [new J2kPoc(0, 0, 2, 3, 3, 1), new J2kPoc(0, 0, 4, 3, 3, 0)]; + o.PocInTileHeader = true; + o.TileWidth = 16; + o.TileHeight = 16; + AssertExact(Img(32, 32, 3), o); + } + + /// Tests that a modest number of repeated progression volumes is deduplicated and still decodes. + [Fact] + public void Jpeg2000Codec_Decode_RepeatedPocEntriesWithinCap_DecodesExactly() + { + var o = Rev(2); + o.Layers = 2; + o.Poc = [.. Enumerable.Repeat(new J2kPoc(0, 0, 2, 3, 1, 0), 20)]; + AssertExact(Img(16, 16, 1), o); + } + + /// + /// Builds a stream whose many overlapping progression volumes (each one layer longer than the last) make the + /// decoder re-visit the same packets over and over: 1024 one-sample precincts and 128 layers. + /// + private static byte[] OverlappingVolumesStream() + { + var o = Rev(0); + o.Layers = 128; + o.Precincts = [(0, 0)]; + o.Poc = [.. Enumerable.Range(1, 128).Select(l => new J2kPoc(0, 0, l, 1, 1, 0))]; + return Encode(Img(32, 32, 1, noise: 0), o); + } + + /// Tests that the cumulative progression budget rejects repeated visits of the same packets once the ceiling is tight. + [Fact] + public void Jpeg2000Codec_Decode_OverlappingProgressionVolumes_ThrowsInvalidData() + { + var data = OverlappingVolumesStream(); + AssertMalformed(data, "progression order iteration exceeds the decoder limit", new Jpeg2000DecoderLimits { MaxProgressionSteps = 1_000_000 }); + + // The default allowance grows with the input (about 64 steps per byte), which a genuine stream of this size + // never exceeds, so the stream itself is valid and the ceiling is the only cause of the rejection above. + _ = Jpeg2000Codec.Decode(data); + } + + /// Tests the progression ceiling with a tight custom limit on a valid stream. + [Fact] + public void Jpeg2000Codec_Decode_TightProgressionLimit_ThrowsInvalidData() + { + var o = Rev(2); + o.Layers = 2; + var data = Encode(Img(16, 16, 1), o); + AssertMalformed(data, "progression order iteration exceeds the decoder limit", new Jpeg2000DecoderLimits { MaxProgressionSteps = 3 }); + _ = Jpeg2000Codec.Decode(data); + } + + /// Tests that the decode-wide budget is cumulative, scales with the input and is capped by the limits. + [Fact] + public void Jpeg2000Codec_DecodeBudget_IsCumulativeAndScalesWithInput() + { + var limits = new Jpeg2000DecoderLimits { MaxProgressionSteps = 1000, MaxTier1Work = 100 }; + var budget = new Jpeg2000Codec.DecodeBudget(limits, 0); + budget.ChargeProgression(600); + Assert.Throws(() => budget.ChargeProgression(600)); + budget.ChargeTier1(100); + Assert.Throws(() => budget.ChargeTier1(1)); + + // With the default limits a tiny input gets only the base allowance, a larger input more, and never more than the limit. + var tiny = new Jpeg2000Codec.DecodeBudget(Jpeg2000DecoderLimits.Default, 10); + tiny.ChargeProgression(1L << 22); + Assert.Throws(() => tiny.ChargeProgression(1L << 12)); + tiny.ChargeTier1(1L << 24); + Assert.Throws(() => tiny.ChargeTier1(1L << 20)); + var large = new Jpeg2000Codec.DecodeBudget(Jpeg2000DecoderLimits.Default, 1L << 18); + large.ChargeTier1((1L << 32) + (1L << 24)); + Assert.Throws(() => large.ChargeTier1(1)); + + // A very large input is capped by the absolute ceiling (2^34), which covers every image the default limits allow. + var huge = new Jpeg2000Codec.DecodeBudget(Jpeg2000DecoderLimits.Default, 1L << 24); + huge.ChargeTier1(1L << 34); + Assert.Throws(() => huge.ChargeTier1(1)); + Assert.True(Jpeg2000DecoderLimits.Default.MaxTier1Work >= Jpeg2000DecoderLimits.Default.MaxTotalSamples * 88); + } + + /// + /// Tests that a large valid lossless flat 16-bit image (4096 x 4096, no decomposition, 64 x 64 blocks: a tiny + /// stream that still needs the maximum number of coding passes per block) decodes under the default limits. + /// Its entropy-decoding work (about 7.2 x 10^8 sample-passes from a 158 KB stream, measured by bisecting MaxTier1Work) exceeded the former per-input-byte allowance. + /// + [Fact] + public void Jpeg2000Codec_Decode_LargeLosslessFlatImage_DecodesUnderDefaultLimits() + { + const int size = 4096; + var image = Img(size, size, 1, depth: 16); + var samples = image.Components[0].Samples; + Array.Fill(samples, 51_200); + samples[0] = 3000; + + var o = Rev(0); + o.CodeBlockWidthExp = 6; + o.CodeBlockHeightExp = 6; + var data = Encode(image, o); + + // The former allowance (2^24 + 2^12 per byte) would have rejected this stream. + Assert.True((1L << 24) + ((1L << 12) * data.Length) < (long)size * size * 42); + var decoded = Jpeg2000Codec.Decode(data); + Assert.Equal(size, decoded.Width); + Assert.Equal(Jpeg2000TestEncoder.ExpectedByte(3000, 16, false), decoded.ColorSamples[0]); + Assert.Equal(Jpeg2000TestEncoder.ExpectedByte(51_200, 16, false), decoded.ColorSamples[1]); + Assert.Equal(Jpeg2000TestEncoder.ExpectedByte(51_200, 16, false), decoded.ColorSamples[^1]); + } + + /// Tests that the entropy-decoding work is bounded by an explicit limit. + [Fact] + public void Jpeg2000Codec_Decode_TightTier1Limit_ThrowsInvalidData() + { + var data = Encode(Img(32, 32, 1), Rev(1)); + AssertMalformed(data, "too much entropy-decoding work", new Jpeg2000DecoderLimits { MaxTier1Work = 1000 }); + _ = Jpeg2000Codec.Decode(data); + } + + /// + /// Tests that a tiny stream declaring huge code-blocks with the maximum number of passes each is rejected by + /// the entropy-decoding budget. The budget is an explicit work cap, so the outcome is deterministic and + /// independent of machine speed. + /// + [Fact] + public void Jpeg2000Codec_Decode_HostileTier1Work_ThrowsInvalidDataWhenOverBudget() + { + var limits = new Jpeg2000DecoderLimits { MaxTier1Work = 1L << 22 }; + AssertMalformed(HostileTier1Stream(2048, 6), "too much entropy-decoding work", limits); + + // Control: the same stream shape at a size whose work (128 x 128 x 88 sample-passes) fits that cap decodes. + Assert.Equal(128, Jpeg2000Codec.Decode(HostileTier1Stream(128, 6), limits).Width); + AssertMalformed(HostileTier1Stream(128, 6), "too much entropy-decoding work", new Jpeg2000DecoderLimits { MaxTier1Work = 1L << 20 }); + } + + /// + /// Tests the largest allowed image shape (8192 x 8192, 64 x 64 blocks, 88 passes per block) from about 60 KB + /// of input against the default budget formula: the input-scaled allowance is far below the work the stream + /// asks for, so it must be rejected. Only the budget arithmetic is exercised; the (CPU-expensive) decode is not. + /// + [Fact] + public void Jpeg2000Codec_Decode_HostileMaximumImageTier1Work_ExceedsDefaultBudget() + { + var data = HostileTier1Stream(8192, 6); + Assert.InRange(data.Length, 60_000, 80_000); + var budget = new Jpeg2000Codec.DecodeBudget(Jpeg2000DecoderLimits.Default, data.Length); + budget.ChargeTier1((1L << 24) + ((1L << 14) * data.Length)); + Assert.Throws(() => budget.ChargeTier1(1)); + Assert.True(8192L * 8192 * 88 > (1L << 24) + ((1L << 14) * data.Length)); + } + /// Tests that the hostile stream shape decodes when the work is within the budget (so the budget is the only cause). + [Fact] + public void Jpeg2000Codec_Decode_SmallHostileShape_Decodes() + { + var image = Jpeg2000Codec.Decode(HostileTier1Stream(128, 6, 10)); + Assert.Equal(128, image.Width); + } + + /// Tests the image-size caps with sizes within the dimension limit so the caps themselves are reached. + [Fact] + public void Jpeg2000Codec_Decode_ImageBeyondDimensionLimit_ThrowsInvalidData() + { + var data = Encode(Img(16, 16, 1), Rev(2)); + AssertMalformed(Resize(data, 8193, 16, 8193, 16), "pixel limit"); + AssertMalformed(Resize(data, 16, 0x7FFFFFFF, 16, 0x7FFFFFFF), "pixel limit"); + AssertMalformed(Resize(data, 4096, 4096, 4096, 4096), "decoder sample limit", new Jpeg2000DecoderLimits { MaxTotalSamples = 1 << 20 }); + AssertMalformed(Resize(data, 4096, 4096, 4096, 4096), "tile exceeds the decoder sample limit", new Jpeg2000DecoderLimits { MaxTileSamples = 1 << 20 }); + } + + /// Tests the tile-count caps: the format maximum at parse time and the configured maximum. + [Fact] + public void Jpeg2000Codec_Decode_TooManyTiles_ThrowsInvalidData() + { + var data = Encode(Img(16, 16, 1), Rev(2)); + AssertMalformed(Resize(data, 8192, 8192, 1, 1), "too many tiles"); + AssertMalformed(Resize(data, 256, 256, 64, 64), "too many tiles", new Jpeg2000DecoderLimits { MaxTiles = 10 }); + + var tiled = Rev(2); + tiled.TileWidth = 64; + tiled.TileHeight = 64; + var tiledData = Encode(Img(256, 256, 1), tiled); + AssertMalformed(tiledData, "too many tiles", new Jpeg2000DecoderLimits { MaxTiles = 15 }); + Assert.Equal(256, Jpeg2000Codec.Decode(tiledData, new Jpeg2000DecoderLimits { MaxTiles = 16 }).Width); + } + + /// Tests that hostile SIZ geometry (32-bit extremes, wrapping tile counts) is rejected quickly as malformed. + [Fact] + public void Jpeg2000Codec_Decode_HostileSizTileGrid_ThrowsInvalidData() + { + var data = Encode(Img(16, 16, 1), Rev(2)); + const uint max = 0xFFFFFFFF; + + // Each axis alone has about 2^32 tiles (XTsiz = 1, grid origin equal to the far image offset). + var xOnly = PatchSiz(data, (6, max), (14, 0), (22, 1), (30, 0)); + AssertMalformed(xOnly, "too many tiles"); + var yOnly = PatchSiz(data, (10, max), (18, 0), (26, 1), (34, 0)); + AssertMalformed(yOnly, "too many tiles"); + + // Both axes huge: the unchecked long product (2^32-1)^2 wraps negative. + var both = PatchSiz(data, (6, max), (10, max), (22, 1), (26, 1), (14, 0), (18, 0), (30, 0), (34, 0)); + AssertMalformed(both, "too many tiles"); + + // Each axis is within the format limit but the product is not. + AssertMalformed(Resize(data, 300, 300, 1, 1), "too many tiles"); + + // Tile size at the 32-bit extreme: one tile per axis, but the image itself exceeds the pixel limit. + var wrap = PatchSiz(data, (6, max), (10, max), (14, 0), (18, 0), (22, max), (26, max), (30, 0), (34, 0)); + AssertMalformed(wrap, "exceed"); + } + + /// Patches 32-bit SIZ fields at offsets relative to the SIZ marker. + private static byte[] PatchSiz(byte[] data, params (int Offset, uint Value)[] fields) + { + var siz = FindMarker(data, 0xFF51); + var copy = (byte[])data.Clone(); + foreach (var (offset, value) in fields) + { + copy[siz + offset] = (byte)(value >> 24); + copy[siz + offset + 1] = (byte)(value >> 16); + copy[siz + offset + 2] = (byte)(value >> 8); + copy[siz + offset + 3] = (byte)value; + } + + return copy; + } + + /// Tests the code-block cap of a tile with a tight custom limit and with the default limit at the maximum image size. + [Fact] + public void Jpeg2000Codec_Decode_TooManyCodeBlocks_ThrowsInvalidData() + { + var o = Rev(0); + o.CodeBlockWidthExp = 2; + o.CodeBlockHeightExp = 2; + var data = Encode(Img(16, 16, 1), o); + AssertMalformed(data, "tile has too many code-blocks", new Jpeg2000DecoderLimits { MaxTileCodeBlocks = 15 }); + _ = Jpeg2000Codec.Decode(data, new Jpeg2000DecoderLimits { MaxTileCodeBlocks = 16 }); + + // 8192 x 8192 samples is within every size limit but needs 4 Mi code-blocks of 4 x 4 samples. + AssertMalformed(Resize(data, 8192, 8192, 8192, 8192), "tile has too many code-blocks"); + } + + /// Tests the precinct cap with a size within the dimension limit. + [Fact] + public void Jpeg2000Codec_Decode_TooManyPrecincts_ThrowsInvalidData() + { + var o = Rev(0); + o.Precincts = [(0, 0)]; + var data = Encode(Img(16, 16, 1), o); + + // 4096 x 4096 one-sample precincts: 16 Mi precincts against the cap of 256 Ki. + AssertMalformed(Resize(data, 4096, 4096, 4096, 4096), "tile has more precincts than the decoder limit"); + AssertMalformed(data, "tile has more precincts than the decoder limit", new Jpeg2000DecoderLimits { MaxTilePrecincts = 255 }); + _ = Jpeg2000Codec.Decode(data, new Jpeg2000DecoderLimits { MaxTilePrecincts = 256 }); + } + + /// Tests the packet cap (precincts times layers) and the bound given by the amount of tile data. + [Fact] + public void Jpeg2000Codec_Decode_TooManyPackets_ThrowsInvalidData() + { + var o = Rev(0); + o.Layers = 3; + o.Precincts = [(4, 4)]; + var data = Encode(Img(32, 32, 1), o); + AssertMalformed(data, "tile has more packets than the decoder limit", new Jpeg2000DecoderLimits { MaxTilePackets = 11 }); + _ = Jpeg2000Codec.Decode(data, new Jpeg2000DecoderLimits { MaxTilePackets = 12 }); + + // 512 x 512 one-sample precincts are within the precinct cap but far more packets than a few hundred bytes can hold. + var tiny = Rev(0); + tiny.Precincts = [(0, 0)]; + var hostile = Resize(Encode(Img(16, 16, 1), tiny), 512, 512, 512, 512); + AssertMalformed(hostile, "tile has more packets than its data can carry"); + } + + /// Tests that a codestream missing a whole tile is rejected. + [Fact] + public void Jpeg2000Codec_Decode_MissingTile_ThrowsInvalidData() + { + var o = Rev(2); + o.TileWidth = 16; + o.TileHeight = 16; + var data = Encode(Img(32, 16, 1), o); + var first = FindMarker(data, 0xFF90); + var second = first + 2; + while (!(data[second] == 0xFF && data[second + 1] == 0x90)) + { + second++; + } + + byte[] cut = [.. data[..second], 0xFF, 0xD9]; + var ex = Assert.Throws(() => Jpeg2000Codec.Decode(cut)); + Assert.Null(ex.InnerException); + _ = Jpeg2000Codec.Decode(data); + } + + /// Tests custom decoder limits. + [Fact] + public void Jpeg2000Codec_Decode_CustomLimits_AreEnforced() + { + var data = Encode(Img(16, 8, 3), Rev(1)); + var strict = new Jpeg2000DecoderLimits { MaxWidth = 8 }; + Assert.Throws(() => Jpeg2000Codec.Decode(data, strict)); + Assert.Throws(() => Jpeg2000Codec.Decode(new MemoryStream(data), strict)); + Assert.Throws(() => Jpeg2000Codec.Decode(data, new Jpeg2000DecoderLimits { MaxTotalSamples = 16 })); + Assert.Throws(() => Jpeg2000Codec.Decode(data, new Jpeg2000DecoderLimits { MaxInputBytes = 10 })); + Assert.Equal(16, Jpeg2000Codec.Decode(data, Jpeg2000DecoderLimits.Default).Width); + } + + /// Tests that invalid limits are rejected as argument errors. + [Fact] + public void Jpeg2000Codec_Decode_InvalidLimits_ThrowsArgumentOutOfRange() + { + var data = Encode(Img(8, 8, 1), Rev(1)); + Assert.Throws(() => Jpeg2000Codec.Decode(data, new Jpeg2000DecoderLimits { MaxWidth = 0 })); + Assert.Throws(() => Jpeg2000Codec.Decode(data, new Jpeg2000DecoderLimits { MaxTotalSamples = -1 })); + Assert.Throws(() => Jpeg2000Codec.Decode(data, null!)); + } + + /// Tests that hostile quantization exponents are rejected during geometry construction. + [Fact] + public void Jpeg2000Codec_Decode_HostileExponents_ThrowsInvalidData() + { + var data = Encode(Img(8, 8, 1), Rev(1)); + var qcd = FindMarker(data, 0xFF5C); + foreach (var exponent in new[] { 31, 40 }) + { + var ex = Assert.Throws(() => Jpeg2000Codec.Decode(Patch(data, qcd + 5, (byte)(exponent << 3)))); + Assert.Null(ex.InnerException); + } + } + + /// Tests that a ROI shift pushing the bit-plane count beyond the decoder maximum is unsupported. + [Fact] + public void Jpeg2000Codec_Decode_RoiShiftBeyondMaxBitPlanes_ThrowsUnsupported() + { + var o = Rev(1); + o.RoiShift = 13; + var data = Encode(Img(8, 8, 1), o); + var rgn = FindMarker(data, 0xFF5E); + Assert.Throws(() => Jpeg2000Codec.Decode(Patch(data, rgn + 6, 30))); + } + + /// Tests that a segmentation symbol that does not decode as 1010 is rejected. + [Fact] + public void Jpeg2000Codec_Decode_WrongSegmentationSymbol_ThrowsInvalidData() + { + var plain = Encode(Img(16, 16, 1), Rev(1)); + var cod = FindMarker(plain, 0xFF52); + var forced = Patch(plain, cod + 12, 0x20); + var ex = Assert.Throws(() => Jpeg2000Codec.Decode(forced)); + Assert.Null(ex.InnerException); + } + + /// Tests that a valid segmentation-symbol stream still decodes exactly. + [Fact] + public void Jpeg2000Codec_Decode_SegmentationSymbols_RoundTripsExactly() + { + var o = Rev(2); + o.CodeBlockStyle = 0x20; + AssertExact(Img(24, 24, 1), o); + } + + /// Tests that four channels in an unrecognized color space are rejected rather than treated as CMYK. + [Fact] + public void Jpeg2000Codec_Decode_FourChannelsInUnknownColorSpace_ThrowsUnsupported() + { + var image = Img(8, 8, 4); + var jp2 = Jpeg2000TestEncoder.WrapJp2(image, Encode(image, Rev(1)), new J2kJp2Options { EnumCs = 99 }); + var ex = Assert.Throws(() => Jpeg2000Codec.Decode(jp2)); + Assert.Equal("jpeg2000-color-space", ex.Feature); + } + + /// Tests that an sRGB image with an undeclared fourth channel keeps only the three color channels. + [Fact] + public void Jpeg2000Codec_Decode_SrgbWithExtraChannelWithoutCdef_IgnoresExtraChannel() + { + var image = Img(8, 8, 4); + var jp2 = Jpeg2000TestEncoder.WrapJp2(image, Encode(image, Rev(1)), new J2kJp2Options { EnumCs = 16 }); + var decoded = Jpeg2000Codec.Decode(jp2); + Assert.Equal(Jpeg2000ColorSpace.Srgb, decoded.ColorSpace); + Assert.Equal(3, decoded.ColorChannelCount); + Assert.False(decoded.HasAlpha); + } + + /// Tests that a raw four-component codestream is treated as CMYK by the documented heuristic. + [Fact] + public void Jpeg2000Codec_Decode_RawFourComponents_IsCmyk() => + Assert.Equal(Jpeg2000ColorSpace.Cmyk, Jpeg2000Codec.Decode(Encode(Img(8, 8, 4), Rev(1))).ColorSpace); +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000FixtureTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000FixtureTests.cs new file mode 100644 index 00000000..7b1d3551 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000FixtureTests.cs @@ -0,0 +1,196 @@ +using DemaConsulting.CanvasNet.Canvas; +using DemaConsulting.CanvasNet.Codecs; + +namespace DemaConsulting.CanvasNet.Tests.Codecs; + +/// +/// Unit tests that exercise against the real-encoder fixture +/// corpus in Jpeg2000Fixtures (see Jpeg2000Fixtures\README.md), generated with +/// ImageMagick/OpenJPEG from synthetic source images. These give evidence that is independent +/// of the test-only Jpeg2000TestEncoder: the expected pixels come from the source PNG +/// files decoded with . +/// +public class Jpeg2000FixtureTests +{ + /// + /// The directory containing the JPEG 2000 fixture corpus, copied to the test output + /// directory by the test project's Jpeg2000Fixtures\** content item. + /// Path.Join is used instead of to avoid + /// CodeQL's cs/path-combine rule. + /// + private static string AssetsPath => Path.Join(AppContext.BaseDirectory, "Jpeg2000Fixtures"); + + /// + /// The exact-match fixtures: file name, source PNG, COD progression order, quality layers, + /// decomposition levels, SIZ component count and SIZ bit depth, all verified from the + /// fixture's own marker bytes. + /// + public static readonly TheoryData LosslessFixtures = + [ + ("lossless_rct.jp2", "source_rgb.png", 0, 1, 5, 3, 8), + ("lossless_raw.j2k", "source_rgb.png", 0, 1, 5, 3, 8), + ("progression_rlcp.jp2", "source_rgb.png", 1, 1, 5, 3, 8), + ("progression_rpcl.jp2", "source_rgb.png", 2, 1, 5, 3, 8), + ("progression_pcrl.jp2", "source_rgb.png", 3, 1, 5, 3, 8), + ("progression_cprl.jp2", "source_rgb.png", 4, 1, 5, 3, 8), + ("resolutions3.jp2", "source_rgb.png", 0, 1, 2, 3, 8), + ("layers3.jp2", "source_rgb.png", 0, 3, 5, 3, 8), + ("gray.jp2", "source_gray.png", 0, 1, 5, 1, 8), + ("alpha.jp2", "source_rgba.png", 0, 1, 5, 4, 8), + ("depth16.jp2", "source_rgb.png", 0, 1, 5, 3, 16) + ]; + + /// Resolves a fixture file name within the corpus directory. + private static string Resolve(string fileName) => Path.Join(AssetsPath, fileName); + + /// Reads the COD and SIZ fields needed for marker assertions from a codestream or JP2 file. + private static (int Progression, int Layers, int Levels, int Components, int Depth) ReadMarkers(byte[] data) + { + // Locate the SOC + SIZ pair (a JP2 file wraps the codestream in boxes) + var pos = 0; + while (!(data[pos] == 0xFF && data[pos + 1] == 0x4F && data[pos + 2] == 0xFF && data[pos + 3] == 0x51)) + { + pos++; + } + + pos += 2; + int progression = -1, layers = 0, levels = 0, components = 0, depth = 0; + while (data[pos] == 0xFF && data[pos + 1] != 0x90) + { + var marker = data[pos + 1]; + var length = (data[pos + 2] << 8) | data[pos + 3]; + var body = pos + 4; + if (marker == 0x51) + { + components = (data[body + 34] << 8) | data[body + 35]; + depth = (data[body + 36] & 0x7F) + 1; + } + else if (marker == 0x52) + { + progression = data[body + 1]; + layers = (data[body + 2] << 8) | data[body + 3]; + levels = data[body + 5]; + } + + pos += 2 + length; + } + + return (progression, layers, levels, components, depth); + } + + /// Asserts every pixel of equals the same pixel of . + private static void AssertSurfacesEqual(Surface expected, Surface actual) + { + Assert.Equal(expected.Width, actual.Width); + Assert.Equal(expected.Height, actual.Height); + for (var y = 0; y < expected.Height; y++) + { + for (var x = 0; x < expected.Width; x++) + { + Assert.True(expected[x, y] == actual[x, y], $"pixel ({x},{y}) expected {expected[x, y]} actual {actual[x, y]}"); + } + } + } + + /// Proves every lossless real-encoder fixture decodes to exactly the source pixels. + [Theory] + [MemberData(nameof(LosslessFixtures))] + public void Jpeg2000Codec_Load_RealEncoderLosslessFixture_MatchesSourcePixelsExactly( + string fixture, string source, int progression, int layers, int levels, int components, int depth) + { + // Arrange: the source PNG (independent of the JPEG 2000 test encoder) and the fixture bytes + var expected = PngCodec.Load(Resolve(source)); + var data = File.ReadAllBytes(Resolve(fixture)); + + // Assert (precondition): the fixture really carries the feature its name claims + var markers = ReadMarkers(data); + Assert.Equal((progression, layers, levels, components, depth), markers); + + // Act + using var actual = Jpeg2000Codec.Load(Resolve(fixture)); + + // Assert: exact pixel equality with the source + AssertSurfacesEqual(expected, actual); + } + + /// Proves the 16-bit and gray fixtures report their native channel layout through Decode. + [Fact] + public void Jpeg2000Codec_Decode_RealEncoderFixtures_ReportChannelLayout() + { + // Act + var gray = Jpeg2000Codec.Decode(File.ReadAllBytes(Resolve("gray.jp2"))); + var alpha = Jpeg2000Codec.Decode(File.ReadAllBytes(Resolve("alpha.jp2"))); + var rgb = Jpeg2000Codec.Decode(File.ReadAllBytes(Resolve("depth16.jp2"))); + + // Assert + Assert.Equal(1, gray.ColorChannelCount); + Assert.Equal(Jpeg2000ColorSpace.Gray, gray.ColorSpace); + Assert.False(gray.HasAlpha); + Assert.Equal(3, alpha.ColorChannelCount); + Assert.True(alpha.HasAlpha); + Assert.Equal(3, rgb.ColorChannelCount); + Assert.Equal(Jpeg2000ColorSpace.Srgb, rgb.ColorSpace); + } + + /// Proves GetInfo reports the dimensions of a real-encoder raw codestream and JP2 file. + [Fact] + public void Jpeg2000Codec_GetInfo_RealEncoderFixtures_ReportDimensions() + { + // Act + var raw = Jpeg2000Codec.GetInfo(Resolve("lossless_raw.j2k")); + var jp2 = Jpeg2000Codec.GetInfo(Resolve("alpha.jp2")); + + // Assert + Assert.Equal(100, raw.Width); + Assert.Equal(70, raw.Height); + Assert.Equal(100, jp2.Width); + Assert.Equal(70, jp2.Height); + } + + /// Proves the lossy truncated real-encoder fixture decodes close to the source (PSNR at least 30 dB). + [Fact] + public void Jpeg2000Codec_Load_RealEncoderLossyFixture_HasAcceptablePsnr() + { + // Arrange + var expected = PngCodec.Load(Resolve("source_rgb.png")); + + // Act + using var actual = Jpeg2000Codec.Load(Resolve("lossy_q30.jp2")); + + // Assert: PSNR over the color channels + Assert.Equal(expected.Width, actual.Width); + Assert.Equal(expected.Height, actual.Height); + double squaredError = 0; + for (var y = 0; y < expected.Height; y++) + { + for (var x = 0; x < expected.Width; x++) + { + var e = expected[x, y]; + var a = actual[x, y]; + squaredError += Math.Pow(e.R - a.R, 2) + Math.Pow(e.G - a.G, 2) + Math.Pow(e.B - a.B, 2); + } + } + + var mse = squaredError / (expected.Width * expected.Height * 3.0); + var psnr = 10 * Math.Log10(255.0 * 255.0 / mse); + Assert.True(psnr >= 30, $"PSNR {psnr:F1} dB below 30 dB"); + } + + /// Proves every truncation of a real-encoder fixture fails closed (never returns pixels). + [Fact] + public void Jpeg2000Codec_Decode_RealEncoderFixtureTruncated_FailsClosed() + { + // Arrange: a small real-encoder codestream + var data = File.ReadAllBytes(Resolve("lossy_q30.jp2")); + + // Act / Assert: each prefix either throws a documented exception or is rejected + for (var length = 0; length < data.Length; length += 7) + { + var truncated = data[..length]; + var ex = Record.Exception(() => Jpeg2000Codec.Decode(truncated)); + Assert.True( + ex is InvalidDataException or UnsupportedImageFeatureException, + $"length {length}: {ex?.GetType().Name ?? "no exception"}"); + } + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000TestEncoder.Coding.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000TestEncoder.Coding.cs new file mode 100644 index 00000000..05e9df61 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000TestEncoder.Coding.cs @@ -0,0 +1,980 @@ +#pragma warning disable S3218, S3358, S1172, S107, S3776, S1541, S134, S1244 +// cspell:ignore bypass ebcot lblock lifting +namespace DemaConsulting.CanvasNet.Tests.Codecs; + +/// +/// Coding primitives of the test-only JPEG 2000 encoder: the MQ arithmetic encoder, the raw +/// (bypass) bit writer, the tier-1 (EBCOT) code-block encoder, the packet header bit writer with +/// its tag tree, and the forward wavelet transforms. Everything here is written from the +/// ITU-T T.800 text independently of the production decoder. +/// +internal static partial class Jpeg2000TestEncoder +{ + /// The Qe values of Table C.2. + internal static readonly int[] MqQe = + [ + 0x5601, 0x3401, 0x1801, 0x0AC1, 0x0521, 0x0221, 0x5601, 0x5401, 0x4801, 0x3801, 0x3001, 0x2401, 0x1C01, 0x1601, + 0x5601, 0x5401, 0x5101, 0x4801, 0x3801, 0x3401, 0x3001, 0x2801, 0x2401, 0x2201, 0x1C01, 0x1801, 0x1601, 0x1401, + 0x1201, 0x1101, 0x0AC1, 0x09C1, 0x08A1, 0x0521, 0x0441, 0x02A1, 0x0221, 0x0141, 0x0111, 0x0085, 0x0049, 0x0025, + 0x0015, 0x0009, 0x0005, 0x0001, 0x5601, + ]; + + /// The NMPS column of Table C.2. + internal static readonly int[] MqNmps = + [ + 1, 2, 3, 4, 5, 38, 7, 8, 9, 10, 11, 12, 13, 29, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, + 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 45, 46, + ]; + + /// The NLPS column of Table C.2. + internal static readonly int[] MqNlps = + [ + 1, 6, 9, 12, 29, 33, 6, 14, 14, 14, 17, 18, 20, 21, 14, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, + 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 46, + ]; + + /// The SWITCH column of Table C.2. + internal static readonly int[] MqSwitch = + [ + 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, + ]; + + /// The MQ arithmetic encoder of ITU-T T.800 Annex C (software conventions). + internal sealed class MqEncoder + { + private readonly int[] _state = new int[19]; + private readonly int[] _mps = new int[19]; + private List _buf = [0]; + private int _bp; + private long _a; + private long _c; + private int _ct; + + /// Sets the contexts to the initial states of Table D.7 (all zero for generic use). + /// Whether to use the tier-1 initial states (UNI 46, RL 3, ZC0 4). + public void ResetContexts(bool tier1) + { + Array.Clear(_state); + Array.Clear(_mps); + if (tier1) + { + _state[0] = 4; + _state[17] = 3; + _state[18] = 46; + } + } + + /// Starts a new codeword segment (INITENC). + public void Start() + { + _buf = [0]; + _bp = 0; + _a = 0x8000; + _c = 0; + _ct = 12; + } + + /// Encodes one decision (ENCODE). + /// The decision. + /// The context. + public void Encode(int d, int cx) + { + if (d == _mps[cx]) + { + CodeMps(cx); + } + else + { + CodeLps(cx); + } + } + + /// Terminates the segment (FLUSH) and returns its bytes without the leading dummy byte. + /// The segment bytes. + public byte[] Flush() + { + SetBits(); + _c <<= _ct; + ByteOut(); + _c <<= _ct; + ByteOut(); + if (_buf[_bp] != 0xFF) + { + _bp++; + } + + // Bytes 1 .. _bp - 1 hold the segment. + return _buf.Skip(1).Take(_bp - 1).ToArray(); + } + + private void CodeMps(int cx) + { + var qe = MqQe[_state[cx]]; + _a -= qe; + if ((_a & 0x8000) == 0) + { + if (_a < qe) + { + _a = qe; + } + else + { + _c += qe; + } + + _state[cx] = MqNmps[_state[cx]]; + Renorm(); + } + else + { + _c += qe; + } + } + + private void CodeLps(int cx) + { + var qe = MqQe[_state[cx]]; + _a -= qe; + if (_a < qe) + { + _c += qe; + } + else + { + _a = qe; + } + + if (MqSwitch[_state[cx]] != 0) + { + _mps[cx] = 1 - _mps[cx]; + } + + _state[cx] = MqNlps[_state[cx]]; + Renorm(); + } + + private void Renorm() + { + do + { + _a <<= 1; + _c <<= 1; + _ct--; + if (_ct == 0) + { + ByteOut(); + } + } + while ((_a & 0x8000) == 0); + } + + private void Put(long value) + { + _bp++; + if (_bp >= _buf.Count) + { + _buf.Add(0); + } + + _buf[_bp] = (byte)value; + } + + private void ByteOut() + { + if (_buf[_bp] == 0xFF) + { + Put(_c >> 20); + _c &= 0xFFFFF; + _ct = 7; + } + else if ((_c & 0x8000000) == 0) + { + Put(_c >> 19); + _c &= 0x7FFFF; + _ct = 8; + } + else + { + _buf[_bp]++; + if (_buf[_bp] == 0xFF) + { + _c &= 0x7FFFFFF; + Put(_c >> 20); + _c &= 0xFFFFF; + _ct = 7; + } + else + { + Put(_c >> 19); + _c &= 0x7FFFF; + _ct = 8; + } + } + } + + private void SetBits() + { + var temp = _c + _a; + _c |= 0xFFFF; + if (_c >= temp) + { + _c -= 0x8000; + } + } + } + + /// Writes raw (selective arithmetic coding bypass) bits with the 0xFF stuffing rule. + internal sealed class RawBitWriter + { + private readonly List _bytes = []; + private int _cur; + private int _free = 8; + + /// Writes one bit. + /// The bit. + public void Write(int bit) + { + _free--; + _cur |= bit << _free; + if (_free == 0) + { + _bytes.Add((byte)_cur); + _free = _cur == 0xFF ? 7 : 8; + _cur = 0; + } + } + + /// Pads the last byte with zero bits, drops a trailing 0xFF and returns the segment. + /// The bytes. + public byte[] Flush() + { + var pending = _free != 8 && !(_free == 7 && _bytes.Count > 0 && _bytes[^1] == 0xFF); + if (pending) + { + _bytes.Add((byte)_cur); + } + + if (_bytes.Count > 0 && _bytes[^1] == 0xFF) + { + _bytes.RemoveAt(_bytes.Count - 1); + } + + var result = _bytes.ToArray(); + _bytes.Clear(); + _cur = 0; + _free = 8; + return result; + } + } + + /// The coded form of one code-block: codeword segments with their pass counts. + internal sealed class EncodedBlock + { + /// Gets or sets the number of coding passes. + public int Passes { get; set; } + + /// Gets or sets the number of missing most significant bit-planes. + public int ZeroBitPlanes { get; set; } + + /// Gets the codeword segments (pass count, bytes). + public List<(int Passes, byte[] Data)> Segments { get; } = []; + } + + /// The tier-1 (EBCOT) code-block encoder. + internal sealed class Tier1Encoder + { + private const byte Sig = 1; + private const byte Visited = 2; + private const byte Refined = 4; + + private readonly MqEncoder _mq = new(); + private readonly RawBitWriter _raw = new(); + private byte[] _flags = []; + private int[] _mag = []; + private bool[] _neg = []; + private int _w; + private int _h; + private int _stride; + private int _orient; + private bool _vcausal; + + /// Encodes a code-block. + /// The signed coefficient values, row-major. + /// The width. + /// The height. + /// The orientation: 0 LL, 1 HL, 2 LH, 3 HH. + /// The code-block style flags. + /// The number of available magnitude bit-planes (Mb plus ROI shift). + /// The encoded block. + public EncodedBlock Encode(int[] values, int w, int h, int orient, int style, int totalPlanes) + { + _w = w; + _h = h; + _orient = orient; + _stride = w + 2; + _vcausal = (style & 0x08) != 0; + _flags = new byte[_stride * (h + 2)]; + _mag = new int[w * h]; + _neg = new bool[w * h]; + var max = 0; + for (var i = 0; i < values.Length; i++) + { + _mag[i] = Math.Abs(values[i]); + _neg[i] = values[i] < 0; + max = Math.Max(max, _mag[i]); + } + + var block = new EncodedBlock(); + var numBps = max == 0 ? 0 : 32 - int.LeadingZeroCount(max); + if (numBps > totalPlanes) + { + throw new InvalidOperationException($"coefficient needs {numBps} bit-planes but only {totalPlanes} are available"); + } + + block.ZeroBitPlanes = totalPlanes - numBps; + if (numBps == 0) + { + return block; + } + + block.Passes = (3 * numBps) - 2; + _mq.ResetContexts(true); + RunPasses(block, numBps, style); + return block; + } + + private void RunPasses(EncodedBlock block, int numBps, int style) + { + var bypass = (style & 0x01) != 0; + var termAll = (style & 0x04) != 0; + var reset = (style & 0x02) != 0; + var segSym = (style & 0x20) != 0; + var bp = numBps - 1; + var segPasses = 0; + var open = false; + var rawMode = false; + for (var pass = 0; pass < block.Passes; pass++) + { + var type = (pass + 2) % 3; + var raw = bypass && pass >= 10 && type != 2; + if (!open) + { + rawMode = raw; + if (rawMode) + { + _raw.Flush(); + } + else + { + _mq.Start(); + } + + open = true; + segPasses = 0; + } + + if (reset) + { + _mq.ResetContexts(true); + } + + switch (type) + { + case 0: + SigProp(bp, raw); + break; + case 1: + MagRef(bp, raw); + break; + default: + Cleanup(bp); + if (segSym) + { + _mq.Encode(1, 18); + _mq.Encode(0, 18); + _mq.Encode(1, 18); + _mq.Encode(0, 18); + } + + bp--; + break; + } + + segPasses++; + var last = pass == block.Passes - 1; + var terminate = last || termAll || (bypass && (pass == 9 || (pass >= 10 && type != 0))); + if (terminate) + { + block.Segments.Add((segPasses, rawMode ? _raw.Flush() : _mq.Flush())); + open = false; + } + } + } + + private int Idx(int x, int y) => ((y + 1) * _stride) + x + 1; + + private bool South(int y) => !(_vcausal && (y & 3) == 3); + + private int Neighbors(int idx, int y, out int h, out int v, out int d) + { + var f = _flags; + h = (f[idx - 1] & Sig) + (f[idx + 1] & Sig); + v = f[idx - _stride] & Sig; + d = (f[idx - _stride - 1] & Sig) + (f[idx - _stride + 1] & Sig); + if (South(y)) + { + v += f[idx + _stride] & Sig; + d += (f[idx + _stride - 1] & Sig) + (f[idx + _stride + 1] & Sig); + } + + return h + v + d; + } + + private int ZeroContext(int idx, int y) + { + Neighbors(idx, y, out var h, out var v, out var d); + if (_orient == 3) + { + var hv = h + v; + if (d >= 3) + { + return 8; + } + + if (d == 2) + { + return hv >= 1 ? 7 : 6; + } + + if (d == 1) + { + return hv >= 2 ? 5 : (hv == 1 ? 4 : 3); + } + + return hv >= 2 ? 2 : hv; + } + + // HL swaps the roles of the horizontal and vertical neighbors. + var a = _orient == 1 ? v : h; + var b = _orient == 1 ? h : v; + if (a == 2) + { + return 8; + } + + if (a == 1) + { + return b >= 1 ? 7 : (d >= 1 ? 6 : 5); + } + + if (b == 2) + { + return 4; + } + + if (b == 1) + { + return 3; + } + + return d >= 2 ? 2 : d; + } + + private int SignContribution(int idx) + { + if ((_flags[idx] & Sig) == 0) + { + return 0; + } + + var x = ((idx % _stride) - 1); + var y = (idx / _stride) - 1; + return _neg[(y * _w) + x] ? -1 : 1; + } + + private void CodeSign(int x, int y, bool raw) + { + var negative = _neg[(y * _w) + x]; + if (raw) + { + _raw.Write(negative ? 1 : 0); + return; + } + + var idx = Idx(x, y); + var hc = Math.Clamp(SignContribution(idx - 1) + SignContribution(idx + 1), -1, 1); + var vsum = SignContribution(idx - _stride) + (South(y) ? SignContribution(idx + _stride) : 0); + var vc = Math.Clamp(vsum, -1, 1); + int cx; + int xorBit; + switch (hc, vc) + { + case (1, 1): cx = 13; xorBit = 0; break; + case (1, 0): cx = 12; xorBit = 0; break; + case (1, -1): cx = 11; xorBit = 0; break; + case (0, 1): cx = 10; xorBit = 0; break; + case (0, 0): cx = 9; xorBit = 0; break; + case (0, -1): cx = 10; xorBit = 1; break; + case (-1, 1): cx = 11; xorBit = 1; break; + case (-1, 0): cx = 12; xorBit = 1; break; + default: cx = 13; xorBit = 1; break; + } + + _mq.Encode((negative ? 1 : 0) ^ xorBit, cx); + } + + private void SigProp(int bp, bool raw) + { + for (var y0 = 0; y0 < _h; y0 += 4) + { + for (var x = 0; x < _w; x++) + { + for (var y = y0; y < Math.Min(y0 + 4, _h); y++) + { + var idx = Idx(x, y); + if ((_flags[idx] & Sig) != 0) + { + continue; + } + + var cx = ZeroContext(idx, y); + if (cx == 0) + { + continue; + } + + var bit = (_mag[(y * _w) + x] >> bp) & 1; + if (raw) + { + _raw.Write(bit); + } + else + { + _mq.Encode(bit, cx); + } + + _flags[idx] |= Visited; + if (bit != 0) + { + CodeSign(x, y, raw); + _flags[idx] |= Sig; + } + } + } + } + } + + private void MagRef(int bp, bool raw) + { + for (var y0 = 0; y0 < _h; y0 += 4) + { + for (var x = 0; x < _w; x++) + { + for (var y = y0; y < Math.Min(y0 + 4, _h); y++) + { + var idx = Idx(x, y); + if ((_flags[idx] & Sig) == 0 || (_flags[idx] & Visited) != 0) + { + continue; + } + + var bit = (_mag[(y * _w) + x] >> bp) & 1; + if (raw) + { + _raw.Write(bit); + } + else + { + int cx; + if ((_flags[idx] & Refined) != 0) + { + cx = 16; + } + else + { + cx = Neighbors(idx, y, out _, out _, out _) > 0 ? 15 : 14; + } + + _mq.Encode(bit, cx); + } + + _flags[idx] |= Refined; + } + } + } + } + + private void Cleanup(int bp) + { + for (var y0 = 0; y0 < _h; y0 += 4) + { + for (var x = 0; x < _w; x++) + { + CleanupColumn(x, y0, bp); + } + } + + for (var i = 0; i < _flags.Length; i++) + { + _flags[i] &= unchecked((byte)~Visited); + } + } + + private void CleanupColumn(int x, int y0, int bp) + { + var start = y0; + if (y0 + 4 <= _h && ColumnIsRunEligible(x, y0)) + { + var first = -1; + for (var k = 0; k < 4; k++) + { + if (((_mag[((y0 + k) * _w) + x] >> bp) & 1) != 0) + { + first = k; + break; + } + } + + if (first < 0) + { + _mq.Encode(0, 17); + return; + } + + _mq.Encode(1, 17); + _mq.Encode(first >> 1, 18); + _mq.Encode(first & 1, 18); + CodeSign(x, y0 + first, false); + _flags[Idx(x, y0 + first)] |= Sig; + start = y0 + first + 1; + } + + for (var y = start; y < Math.Min(y0 + 4, _h); y++) + { + var idx = Idx(x, y); + if ((_flags[idx] & (Sig | Visited)) != 0) + { + continue; + } + + var bit = (_mag[(y * _w) + x] >> bp) & 1; + _mq.Encode(bit, ZeroContext(idx, y)); + if (bit != 0) + { + CodeSign(x, y, false); + _flags[idx] |= Sig; + } + } + } + + private bool ColumnIsRunEligible(int x, int y0) + { + for (var k = 0; k < 4; k++) + { + var idx = Idx(x, y0 + k); + if ((_flags[idx] & (Sig | Visited)) != 0 || Neighbors(idx, y0 + k, out _, out _, out _) != 0) + { + return false; + } + } + + return true; + } + } + + /// Writes packet header bits with the 0xFF bit-stuffing rule. + internal sealed class HeaderBitWriter + { + private readonly List _bytes = []; + private int _cur; + private int _free = 8; + + /// Writes one bit. + /// The bit. + public void Bit(int bit) + { + _free--; + _cur |= (bit & 1) << _free; + if (_free == 0) + { + _bytes.Add((byte)_cur); + _free = _cur == 0xFF ? 7 : 8; + _cur = 0; + } + } + + /// Writes bits, most significant first. + /// The value. + /// The number of bits. + public void Bits(int value, int count) + { + for (var i = count - 1; i >= 0; i--) + { + Bit((value >> i) & 1); + } + } + + /// Pads to a byte boundary and returns the header bytes. + /// The bytes. + public byte[] Finish() + { + if (_free != 8 && !(_free == 7 && _bytes.Count > 0 && _bytes[^1] == 0xFF && _cur == 0)) + { + _bytes.Add((byte)_cur); + } + + if (_bytes.Count > 0 && _bytes[^1] == 0xFF) + { + _bytes.Add(0); + } + + return _bytes.ToArray(); + } + } + + /// A tag tree encoder (ITU-T T.800 B.10.2). + internal sealed class TagTreeEncoder + { + private readonly int[] _value; + private readonly int[] _low; + private readonly bool[] _known; + private readonly int[] _offset; + private readonly int[] _width; + + /// Initializes a tree over a by leaf grid. + /// The number of leaf columns. + /// The number of leaf rows. + /// The leaf values in raster order. + public TagTreeEncoder(int w, int h, int[] leaves) + { + var offsets = new List(); + var widths = new List(); + var total = 0; + var cw = w; + var ch = h; + while (true) + { + offsets.Add(total); + widths.Add(cw); + total += cw * ch; + if (cw == 1 && ch == 1) + { + break; + } + + cw = (cw + 1) / 2; + ch = (ch + 1) / 2; + } + + _offset = [.. offsets]; + _width = [.. widths]; + _value = new int[total]; + _low = new int[total]; + _known = new bool[total]; + Array.Fill(_value, int.MaxValue); + Array.Copy(leaves, _value, leaves.Length); + var levelW = w; + var levelH = h; + for (var level = 1; level < _offset.Length; level++) + { + var pw = (levelW + 1) / 2; + var ph = (levelH + 1) / 2; + for (var y = 0; y < levelH; y++) + { + for (var x = 0; x < levelW; x++) + { + var child = _value[_offset[level - 1] + (y * levelW) + x]; + var parent = _offset[level] + ((y / 2) * pw) + (x / 2); + _value[parent] = Math.Min(_value[parent], child); + } + } + + levelW = pw; + levelH = ph; + } + } + + /// Emits the bits that tell a decoder whether the leaf value is below . + /// The bit writer. + /// The leaf column. + /// The leaf row. + /// The threshold. + public void Encode(HeaderBitWriter bw, int x, int y, int threshold) + { + var low = 0; + for (var level = _offset.Length - 1; level >= 0; level--) + { + var node = _offset[level] + ((y >> level) * _width[level]) + (x >> level); + if (low > _low[node]) + { + _low[node] = low; + } + else + { + low = _low[node]; + } + + while (low < threshold) + { + if (low >= _value[node]) + { + if (!_known[node]) + { + bw.Bit(1); + _known[node] = true; + } + + break; + } + + bw.Bit(0); + low++; + } + + _low[node] = low; + } + } + } + + // ------------------------------------------------------------------------------------------ + // Forward wavelet transforms (ITU-T T.800 Annex F), operating on a Mallat-layout plane + // ------------------------------------------------------------------------------------------ + + private const double Alpha = -1.586134342059924; + private const double Beta = -0.052980118572961; + private const double Gamma = 0.882911075530934; + private const double Delta = 0.443506852043971; + private const double K = 1.230174104914001; + + /// Applies the forward 5/3 transform of one line in place (interleaved, absolute parity ). + private static void Forward53(int[] x, int n, int parity) + { + if (n == 1) + { + if (parity == 1) + { + x[0] *= 2; + } + + return; + } + + for (var i = 1 - parity; i < n; i += 2) + { + x[i] -= (x[Mirror(i - 1, n)] + x[Mirror(i + 1, n)]) >> 1; + } + + for (var i = parity; i < n; i += 2) + { + x[i] += (x[Mirror(i - 1, n)] + x[Mirror(i + 1, n)] + 2) >> 2; + } + } + + /// Applies the forward 9/7 transform of one line in place. + private static void Forward97(double[] x, int n, int parity) + { + if (n == 1) + { + if (parity == 1) + { + x[0] *= 2; + } + + return; + } + + Lift(x, n, 1 - parity, Alpha); + Lift(x, n, parity, Beta); + Lift(x, n, 1 - parity, Gamma); + Lift(x, n, parity, Delta); + for (var i = 0; i < n; i++) + { + x[i] *= ((i + parity) & 1) == 0 ? 1.0 / K : K; + } + } + + private static void Lift(double[] x, int n, int first, double coefficient) + { + for (var i = first; i < n; i += 2) + { + x[i] += coefficient * (x[Mirror(i - 1, n)] + x[Mirror(i + 1, n)]); + } + } + + private static int Mirror(int i, int n) + { + if (i < 0) + { + return -i; + } + + return i >= n ? (2 * (n - 1)) - i : i; + } + + /// Runs the forward 2-D transform over all decomposition levels of a tile-component plane. + /// The sample type ( or ). + /// The plane, row stride . + /// The row stride. + /// Per resolution (lowest first): x0, y0, x1, y1 in resolution coordinates. + /// The 1-D transform. + private static void ForwardDwt(T[] plane, int stride, (long X0, long Y0, long X1, long Y1)[] rects, Action line) + { + for (var r = rects.Length - 1; r >= 1; r--) + { + var rc = rects[r]; + var w = (int)(rc.X1 - rc.X0); + var h = (int)(rc.Y1 - rc.Y0); + var px = (int)(rc.X0 & 1); + var py = (int)(rc.Y0 & 1); + var buf = new T[Math.Max(w, h)]; + for (var x = 0; x < w; x++) + { + for (var y = 0; y < h; y++) + { + buf[y] = plane[(y * stride) + x]; + } + + line(buf, h, py); + Deinterleave(buf, h, py, plane, x, stride); + } + + for (var y = 0; y < h; y++) + { + for (var x = 0; x < w; x++) + { + buf[x] = plane[(y * stride) + x]; + } + + line(buf, w, px); + Deinterleave(buf, w, px, plane, y * stride, 1); + } + } + } + + private static void Deinterleave(T[] buf, int n, int parity, T[] plane, int start, int step) + { + var lowCount = 0; + for (var i = 0; i < n; i++) + { + if (((i + parity) & 1) == 0) + { + lowCount++; + } + } + + var lo = 0; + var hi = lowCount; + for (var i = 0; i < n; i++) + { + if (((i + parity) & 1) == 0) + { + plane[start + (lo++ * step)] = buf[i]; + } + else + { + plane[start + (hi++ * step)] = buf[i]; + } + } + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000TestEncoder.Markers.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000TestEncoder.Markers.cs new file mode 100644 index 00000000..b9c4eb5b --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000TestEncoder.Markers.cs @@ -0,0 +1,505 @@ +#pragma warning disable S3218, S3358, S1172, S107, S3776, S1541, S134, S1244 +// cspell:ignore ppm ppt tlm plt crg rgn poc cprl rpcl pcrl rlcp lrcp sop eph xcb ycb +// cspell:ignore pclr cmap cdef bpcc colr ihdr ftyp jp2c jp2h +using System.Text; + +namespace DemaConsulting.CanvasNet.Tests.Codecs; + +/// JP2 wrapper options of . +internal sealed class J2kJp2Options +{ + /// Gets or sets the enumerated color space (16 sRGB, 17 grayscale, 18 sYCC, 12 CMYK); ignored when an ICC profile is set. + public int EnumCs { get; set; } = 16; + + /// Gets or sets an ICC profile written with METH 2, or . + public byte[]? Icc { get; set; } + + /// Gets or sets a value indicating whether a bpcc box is written. + public bool WriteBpcc { get; set; } + + /// Gets or sets the palette entries (entries by columns), or . + public int[][]? Palette { get; set; } + + /// Gets or sets the palette column bit depths. + public int[] PaletteDepths { get; set; } = []; + + /// Gets or sets the component mapping records, or . + public (int Component, int Type, int Column)[]? Cmap { get; set; } + + /// Gets or sets the channel definition records, or . + public (int Channel, int Type, int Association)[]? Cdef { get; set; } + + /// Gets or sets a value indicating whether the codestream box uses an extended (64-bit) length. + public bool ExtendedLength { get; set; } + + /// Gets or sets a value indicating whether the codestream box length is written as zero (to end of file). + public bool ZeroLength { get; set; } +} + +internal static partial class Jpeg2000TestEncoder +{ + // ------------------------------------------------------------------------------------------ + // Marker writers + // ------------------------------------------------------------------------------------------ + + private static void WriteMarker(List dst, int marker) + { + dst.Add((byte)(marker >> 8)); + dst.Add((byte)marker); + } + + private static void Put16(List dst, int v) + { + dst.Add((byte)(v >> 8)); + dst.Add((byte)v); + } + + private static void Put32(List dst, long v) + { + dst.Add((byte)(v >> 24)); + dst.Add((byte)(v >> 16)); + dst.Add((byte)(v >> 8)); + dst.Add((byte)v); + } + + private static void WriteSegment(List dst, int marker, List payload) + { + WriteMarker(dst, marker); + Put16(dst, payload.Count + 2); + dst.AddRange(payload); + } + + private static void WriteSiz(List dst, J2kImage image, J2kOptions o, int tw, int th) + { + var p = new List(); + Put16(p, 0); + Put32(p, image.Xsiz); + Put32(p, image.Ysiz); + Put32(p, image.XOsiz); + Put32(p, image.YOsiz); + Put32(p, tw); + Put32(p, th); + Put32(p, o.TileXOffset); + Put32(p, o.TileYOffset); + Put16(p, image.Components.Length); + foreach (var c in image.Components) + { + p.Add((byte)((c.Depth - 1) | (c.Signed ? 0x80 : 0))); + p.Add((byte)c.Dx); + p.Add((byte)c.Dy); + } + + WriteSegment(dst, 0xFF51, p); + } + + private static void WriteSpParams(List p, J2kOptions o, int levels) + { + p.Add((byte)levels); + p.Add((byte)(o.CodeBlockWidthExp - 2)); + p.Add((byte)(o.CodeBlockHeightExp - 2)); + p.Add((byte)o.CodeBlockStyle); + p.Add((byte)(o.Reversible ? 1 : 0)); + if (o.Precincts is not null) + { + for (var r = 0; r <= levels; r++) + { + var (px, py) = PrecinctOf(o, r); + p.Add((byte)((py << 4) | px)); + } + } + } + + private static List QuantBytes(J2kImage image, J2kOptions o, int c) + { + var levels = LevelsOf(o, c); + var p = new List(); + if (o.Reversible) + { + p.Add((byte)(o.GuardBits << 5)); + for (var r = 0; r <= levels; r++) + { + foreach (var orient in r == 0 ? new[] { 0 } : new[] { 1, 2, 3 }) + { + p.Add((byte)(QuantFor(image, o, c, levels, r, orient).Eps << 3)); + } + } + + return p; + } + + if (o.QuantStyle == 1) + { + p.Add((byte)((o.GuardBits << 5) | 1)); + var (eps, mant) = QuantFor(image, o, c, levels, 0, 0); + Put16(p, (eps << 11) | mant); + return p; + } + + p.Add((byte)((o.GuardBits << 5) | 2)); + for (var r = 0; r <= levels; r++) + { + foreach (var orient in r == 0 ? new[] { 0 } : new[] { 1, 2, 3 }) + { + var (eps, mant) = QuantFor(image, o, c, levels, r, orient); + Put16(p, (eps << 11) | mant); + } + } + + return p; + } + + /// Writes COD, COC, QCD, QCC and RGN markers for . + private static void WriteParamMarkers(List dst, J2kImage image, J2kOptions o) + { + var n = image.Components.Length; + var cod = new List + { + (byte)((o.Precincts is not null ? 1 : 0) | (o.Sop ? 2 : 0) | (o.Eph ? 4 : 0)), + (byte)o.Progression, + }; + Put16(cod, o.Layers); + cod.Add((byte)(o.Mct ? 1 : 0)); + WriteSpParams(cod, o, o.Levels); + WriteSegment(dst, 0xFF52, cod); + for (var c = 0; c < n; c++) + { + if (LevelsOf(o, c) != o.Levels) + { + var coc = new List { (byte)c, (byte)(o.Precincts is not null ? 1 : 0) }; + WriteSpParams(coc, o, LevelsOf(o, c)); + WriteSegment(dst, 0xFF53, coc); + } + } + + var qcd = QuantBytes(image, o, 0); + WriteSegment(dst, 0xFF5C, qcd); + for (var c = 1; c < n; c++) + { + var q = QuantBytes(image, o, c); + if (!q.SequenceEqual(qcd)) + { + var qcc = new List { (byte)c }; + qcc.AddRange(q); + WriteSegment(dst, 0xFF5D, qcc); + } + } + + if (o.RoiShift > 0) + { + for (var c = 0; c < n; c++) + { + if (o.RoiComponents is null || o.RoiComponents.Contains(c)) + { + WriteSegment(dst, 0xFF5E, [(byte)c, 0, (byte)o.RoiShift]); + } + } + } + } + + private static void WritePocMarker(List dst, List poc) + { + var p = new List(); + foreach (var e in poc) + { + p.Add((byte)e.ResStart); + p.Add((byte)e.CompStart); + Put16(p, e.LayerEnd); + p.Add((byte)e.ResEnd); + p.Add((byte)(e.CompEnd & 0xFF)); + p.Add((byte)e.Order); + } + + WriteSegment(dst, 0xFF5F, p); + } + + private static void WriteCodingMarkers(List dst, J2kImage image, J2kOptions o, int tileParts) + { + _ = tileParts; + WriteParamMarkers(dst, image, o); + if (o.Poc is not null && !o.PocInTileHeader) + { + WritePocMarker(dst, o.Poc); + } + } + + private static void WriteExtraMainMarkers(List dst, J2kImage image, List<(int Tile, byte[] Bytes)> tileParts) + { + WriteMarker(dst, 0xFF30); + var tlm = new List { 0, 0x60 }; + foreach (var (tile, bytes) in tileParts) + { + Put16(tlm, tile); + Put32(tlm, bytes.Length); + } + + WriteSegment(dst, 0xFF55, tlm); + var crg = new List(); + for (var c = 0; c < image.Components.Length; c++) + { + Put16(crg, 0); + Put16(crg, 0); + } + + WriteSegment(dst, 0xFF63, crg); + var com = new List { 0, 1 }; + com.AddRange(Encoding.ASCII.GetBytes("Jpeg2000TestEncoder")); + WriteSegment(dst, 0xFF64, com); + } + + private static void WritePpm(List dst, List chunks, int chunkSize) + { + var stream = new List(); + foreach (var c in chunks) + { + Put32(stream, c.Length); + stream.AddRange(c); + } + + var max = chunkSize > 0 ? chunkSize : 65535 - 3; + var z = 0; + for (var pos = 0; pos < stream.Count || z == 0; pos += max) + { + var len = Math.Min(max, stream.Count - pos); + var seg = new List { (byte)z++ }; + seg.AddRange(stream.GetRange(pos, len)); + WriteSegment(dst, 0xFF60, seg); + if (len == 0) + { + break; + } + } + } + + private static void AppendTileParts( + int tile, J2kOptions main, J2kOptions p, bool isOverride, List packets, List<(int Tile, byte[] Bytes)> parts, List ppmChunks, J2kImage image) + { + var k = Math.Max(1, Math.Min(main.TileParts, packets.Count)); + for (var part = 0; part < k; part++) + { + var group = packets.Skip(packets.Count * part / k).Take((packets.Count * (part + 1) / k) - (packets.Count * part / k)).ToList(); + var body = new List(); + var headers = new List(); + foreach (var pk in group) + { + body.AddRange(main.HeaderMode == J2kHeaderMode.InBand ? pk.Sop.Concat(pk.Header).Concat(pk.Data) : pk.Sop.Concat(pk.Data)); + headers.AddRange(pk.Header); + } + + var hdr = new List(); + if (part == 0) + { + if (isOverride) + { + WriteParamMarkers(hdr, image, p); + } + + if (p.Poc is not null && (isOverride || main.PocInTileHeader)) + { + WritePocMarker(hdr, main.PocSplitAcrossParts ? [.. p.Poc.Take(1)] : p.Poc); + } + } + else + { + if (main.PocSplitAcrossParts && part == 1 && p.Poc is { Count: > 1 }) + { + WritePocMarker(hdr, [.. p.Poc.Skip(1)]); + } + + if (main.LaterPartSegments is not null) + { + hdr.AddRange(main.LaterPartSegments); + } + } + + if (main.ExtraMarkers) + { + var plt = new List { (byte)part }; + foreach (var pk in group) + { + WriteVarInt(plt, pk.Sop.Length + pk.Header.Length + pk.Data.Length); + } + + WriteSegment(hdr, 0xFF58, plt); + WriteSegment(hdr, 0xFF64, [0, 1, (byte)'t']); + } + + if (main.HeaderMode == J2kHeaderMode.Ppm) + { + ppmChunks.Add([.. headers]); + } + else if (main.HeaderMode == J2kHeaderMode.Ppt) + { + WritePpt(hdr, headers, main.PackedChunkSize); + } + + var bytes = new List(); + WriteMarker(bytes, 0xFF90); + Put16(bytes, 10); + Put16(bytes, tile); + Put32(bytes, 12 + hdr.Count + 2 + body.Count); + bytes.Add((byte)part); + bytes.Add((byte)k); + bytes.AddRange(hdr); + WriteMarker(bytes, 0xFF93); + bytes.AddRange(body); + parts.Add((tile, [.. bytes])); + } + } + + private static void WriteVarInt(List dst, int value) + { + var groups = new List(); + do + { + groups.Insert(0, (byte)(value & 0x7F)); + value >>= 7; + } + while (value > 0); + for (var i = 0; i < groups.Count - 1; i++) + { + groups[i] |= 0x80; + } + + dst.AddRange(groups); + } + + private static void WritePpt(List dst, List headers, int chunkSize) + { + var max = chunkSize > 0 ? chunkSize : 65535 - 3; + var z = 0; + for (var pos = 0; pos < headers.Count || z == 0; pos += max) + { + var len = Math.Min(max, headers.Count - pos); + var seg = new List { (byte)z++ }; + seg.AddRange(headers.GetRange(pos, len)); + WriteSegment(dst, 0xFF61, seg); + if (len == 0) + { + break; + } + } + } + + // ------------------------------------------------------------------------------------------ + // JP2 wrapper + // ------------------------------------------------------------------------------------------ + + private static void WriteBox(List dst, string type, IEnumerable payload) + { + var data = payload.ToArray(); + Put32(dst, data.Length + 8); + dst.AddRange(Encoding.ASCII.GetBytes(type)); + dst.AddRange(data); + } + + /// Wraps a codestream in a JP2 file. + /// The image the codestream was made from. + /// The codestream. + /// The wrapper options. + /// The JP2 file bytes. + public static byte[] WrapJp2(J2kImage image, byte[] codestream, J2kJp2Options o) + { + var file = new List(); + WriteBox(file, "jP ", [0x0D, 0x0A, 0x87, 0x0A]); + WriteBox(file, "ftyp", [.. Encoding.ASCII.GetBytes("jp2 "), 0, 0, 0, 0, .. Encoding.ASCII.GetBytes("jp2 ")]); + var header = new List(); + var ihdr = new List(); + Put32(ihdr, image.Height); + Put32(ihdr, image.Width); + Put16(ihdr, image.Components.Length); + var uniform = image.Components.All(c => c.Depth == image.Components[0].Depth && c.Signed == image.Components[0].Signed); + ihdr.Add(uniform ? (byte)((image.Components[0].Depth - 1) | (image.Components[0].Signed ? 0x80 : 0)) : (byte)255); + ihdr.AddRange([7, 0, 0]); + WriteBox(header, "ihdr", ihdr); + if (o.WriteBpcc) + { + WriteBox(header, "bpcc", image.Components.Select(c => (byte)((c.Depth - 1) | (c.Signed ? 0x80 : 0)))); + } + + var colr = new List(); + if (o.Icc is not null) + { + colr.AddRange([2, 0, 0]); + colr.AddRange(o.Icc); + } + else + { + colr.AddRange([1, 0, 0]); + Put32(colr, o.EnumCs); + } + + WriteBox(header, "colr", colr); + if (o.Palette is { } palette) + { + var pclr = new List(); + Put16(pclr, palette.Length); + pclr.Add((byte)o.PaletteDepths.Length); + pclr.AddRange(o.PaletteDepths.Select(d => (byte)(d - 1))); + foreach (var entry in palette) + { + for (var col = 0; col < o.PaletteDepths.Length; col++) + { + if (o.PaletteDepths[col] > 8) + { + Put16(pclr, entry[col]); + } + else + { + pclr.Add((byte)entry[col]); + } + } + } + + WriteBox(header, "pclr", pclr); + } + + if (o.Cmap is { } cmap) + { + var b = new List(); + foreach (var (comp, type, col) in cmap) + { + Put16(b, comp); + b.Add((byte)type); + b.Add((byte)col); + } + + WriteBox(header, "cmap", b); + } + + if (o.Cdef is { } cdef) + { + var b = new List(); + Put16(b, cdef.Length); + foreach (var (channel, type, assoc) in cdef) + { + Put16(b, channel); + Put16(b, type); + Put16(b, assoc); + } + + WriteBox(header, "cdef", b); + } + + WriteBox(file, "jp2h", header); + if (o.ZeroLength) + { + Put32(file, 0); + file.AddRange(Encoding.ASCII.GetBytes("jp2c")); + } + else if (o.ExtendedLength) + { + Put32(file, 1); + file.AddRange(Encoding.ASCII.GetBytes("jp2c")); + Put32(file, 0); + Put32(file, codestream.Length + 16); + } + else + { + Put32(file, codestream.Length + 8); + file.AddRange(Encoding.ASCII.GetBytes("jp2c")); + } + + file.AddRange(codestream); + return [.. file]; + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000TestEncoder.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000TestEncoder.cs new file mode 100644 index 00000000..51d8221f --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000TestEncoder.cs @@ -0,0 +1,1070 @@ +#pragma warning disable S3218, S3358, S1172, S107, S3776, S1541, S134, S1244 +// cspell:ignore bypass ebcot lblock precinct precincts subband subbands tlm plt ppm ppt crg sop eph rgn poc cprl rpcl pcrl rlcp lrcp +// cspell:ignore pclr cmap cdef bpcc colr ihdr ftyp jp2c jp2h tilepart xcb ycb +using System.Text; + +namespace DemaConsulting.CanvasNet.Tests.Codecs; + +/// One image component handed to . +internal sealed class J2kComponent +{ + /// Gets or sets the bit depth (1 to 16). + public int Depth { get; set; } = 8; + + /// Gets or sets a value indicating whether samples are signed. + public bool Signed { get; set; } + + /// Gets or sets the horizontal subsampling factor. + public int Dx { get; set; } = 1; + + /// Gets or sets the vertical subsampling factor. + public int Dy { get; set; } = 1; + + /// Gets or sets the samples of the component grid, row-major, in natural (signed or unsigned) values. + public int[] Samples { get; set; } = []; +} + +/// One progression order change entry. +/// The first resolution. +/// The first component. +/// The exclusive layer end. +/// The exclusive resolution end. +/// The exclusive component end. +/// The progression order (0 LRCP, 1 RLCP, 2 RPCL, 3 PCRL, 4 CPRL). +internal readonly record struct J2kPoc(int ResStart, int CompStart, int LayerEnd, int ResEnd, int CompEnd, int Order); + +/// An image on the reference grid. +internal sealed class J2kImage +{ + /// Gets or sets the right edge of the reference grid. + public int Xsiz { get; set; } + + /// Gets or sets the bottom edge of the reference grid. + public int Ysiz { get; set; } + + /// Gets or sets the horizontal image offset. + public int XOsiz { get; set; } + + /// Gets or sets the vertical image offset. + public int YOsiz { get; set; } + + /// Gets or sets the components. + public J2kComponent[] Components { get; set; } = []; + + /// Gets the image width. + public int Width => Xsiz - XOsiz; + + /// Gets the image height. + public int Height => Ysiz - YOsiz; + + /// Gets the left edge of component in component samples. + public int CompX0(int c) => (XOsiz + Components[c].Dx - 1) / Components[c].Dx; + + /// Gets the top edge of component in component samples. + public int CompY0(int c) => (YOsiz + Components[c].Dy - 1) / Components[c].Dy; + + /// Gets the width of component in component samples. + public int CompWidth(int c) => ((Xsiz + Components[c].Dx - 1) / Components[c].Dx) - CompX0(c); + + /// Gets the height of component in component samples. + public int CompHeight(int c) => ((Ysiz + Components[c].Dy - 1) / Components[c].Dy) - CompY0(c); +} + +/// Where packet headers are stored. +internal enum J2kHeaderMode +{ + /// In-line with the packet data. + InBand, + + /// Packed in PPM marker segments of the main header. + Ppm, + + /// Packed in PPT marker segments of the tile-part headers. + Ppt, +} + +/// Coding options of . +internal sealed class J2kOptions +{ + /// Gets or sets the tile width (0 = one tile). + public int TileWidth { get; set; } + + /// Gets or sets the tile height (0 = one tile). + public int TileHeight { get; set; } + + /// Gets or sets the tile grid horizontal offset. + public int TileXOffset { get; set; } + + /// Gets or sets the tile grid vertical offset. + public int TileYOffset { get; set; } + + /// Gets or sets the number of decomposition levels. + public int Levels { get; set; } = 3; + + /// Gets or sets per-component decomposition levels (emitted as COC/QCC), or . + public int[]? ComponentLevels { get; set; } + + /// Gets or sets a value indicating whether the reversible 5/3 transform is used. + public bool Reversible { get; set; } = true; + + /// Gets or sets a value indicating whether the component transform (RCT/ICT) is used. + public bool Mct { get; set; } + + /// Gets or sets the code-block width exponent. + public int CodeBlockWidthExp { get; set; } = 5; + + /// Gets or sets the code-block height exponent. + public int CodeBlockHeightExp { get; set; } = 5; + + /// Gets or sets the code-block style flags (0x01 bypass, 0x02 reset, 0x04 termall, 0x08 vcausal, 0x10 pterm, 0x20 segsym). + public int CodeBlockStyle { get; set; } + + /// Gets or sets the progression order (0 LRCP, 1 RLCP, 2 RPCL, 3 PCRL, 4 CPRL). + public int Progression { get; set; } + + /// Gets or sets the number of quality layers. + public int Layers { get; set; } = 1; + + /// Gets or sets explicit precinct exponents (PPx, PPy) per resolution (lowest first; the last entry repeats), or . + public (int Ppx, int Ppy)[]? Precincts { get; set; } + + /// Gets or sets a value indicating whether SOP marker segments are written. + public bool Sop { get; set; } + + /// Gets or sets a value indicating whether EPH markers are written. + public bool Eph { get; set; } + + /// Gets or sets the maximum-shift region-of-interest value (0 = none). + public int RoiShift { get; set; } + + /// Gets or sets the components that carry an ROI (null = all). + public int[]? RoiComponents { get; set; } + + /// Gets or sets the number of guard bits. + public int GuardBits { get; set; } = 2; + + /// Gets or sets the quantization style for irreversible coding: 1 derived, 2 expounded. + public int QuantStyle { get; set; } = 2; + + /// Gets or sets a value indicating whether non-zero step size mantissas are used. + public bool QuantMantissa { get; set; } + + /// Gets or sets progression order changes (written as a POC marker), or . + public List? Poc { get; set; } + + /// Gets or sets a value indicating whether the POC marker is placed in the tile-part headers instead of the main header. + public bool PocInTileHeader { get; set; } + + /// Gets or sets a value indicating whether the tile-part POC entries are spread over the tile-part headers (first entry in the first, the rest in the second) instead of all being in the first. + public bool PocSplitAcrossParts { get; set; } + + /// Gets or sets complete marker segments (marker included) appended to the header of every tile-part after the first. + public byte[]? LaterPartSegments { get; set; } + + /// Gets or sets where packet headers are stored. + public J2kHeaderMode HeaderMode { get; set; } + + /// Gets or sets the maximum payload of a PPM/PPT marker segment (0 = unlimited). + public int PackedChunkSize { get; set; } + + /// Gets or sets the number of tile-parts per tile. + public int TileParts { get; set; } = 1; + + /// Gets or sets a value indicating whether COM, CRG, TLM, PLT and reserved markers are written. + public bool ExtraMarkers { get; set; } + + /// Gets or sets per-tile coding overrides written in the tile-part headers. + public Dictionary TileOverrides { get; private set; } = []; + + /// Gets or sets a value indicating whether the last tile-part uses Psot = 0 and the EOC marker is omitted. + public bool OmitEoc { get; set; } + + /// Gets or sets a value indicating whether Psot is written as zero for the final tile-part. + public bool ZeroPsotOnLast { get; set; } + + /// Creates a shallow copy of the options (without tile overrides). + /// The copy. + public J2kOptions Clone() + { + var copy = (J2kOptions)MemberwiseClone(); + copy.TileOverrides = []; + return copy; + } +} + +/// +/// A minimal, test-only JPEG 2000 Part 1 encoder. It exists so that the production decoder can be +/// exercised on every coding feature (5/3 and 9/7, RCT/ICT, tiles, precincts, all progression orders, +/// layers, every code-block style, ROI, packed headers, subsampling, JP2 boxes) without third-party files. +/// +internal static partial class Jpeg2000TestEncoder +{ + // ------------------------------------------------------------------------------------------ + // Geometry model + // ------------------------------------------------------------------------------------------ + + private sealed class EBand + { + public int Orient { get; init; } + + public long X0 { get; init; } + + public long Y0 { get; init; } + + public long X1 { get; init; } + + public long Y1 { get; init; } + + public int Eps { get; init; } + + public int Mant { get; init; } + + public int Mb { get; init; } + + public double Step { get; init; } + + public int OffX { get; init; } + + public int OffY { get; init; } + } + + private sealed class EPrecBand + { + public required EBand Band { get; init; } + + public int Cw { get; init; } + + public int Ch { get; init; } + + public EncodedBlock[] Blocks { get; init; } = []; + + public int[][] Cum { get; set; } = []; + + public bool[] Included { get; set; } = []; + + public int[] Lblock { get; set; } = []; + + public TagTreeEncoder? Inclusion { get; set; } + + public TagTreeEncoder? ZeroBits { get; set; } + } + + private sealed class EPrecinct + { + public required EPrecBand[] Bands { get; init; } + } + + private sealed class ERes + { + public long X0 { get; init; } + + public long Y0 { get; init; } + + public long X1 { get; init; } + + public long Y1 { get; init; } + + public int PPx { get; init; } + + public int PPy { get; init; } + + public int Pw { get; init; } + + public int Ph { get; init; } + + public required EPrecinct[] Precincts { get; init; } + } + + private sealed class ETileComp + { + public required ERes[] Res { get; init; } + + public int Levels { get; init; } + } + + private sealed class Packet + { + public byte[] Sop { get; init; } = []; + + public byte[] Header { get; init; } = []; + + public byte[] Data { get; init; } = []; + } + + // ------------------------------------------------------------------------------------------ + // Public API + // ------------------------------------------------------------------------------------------ + + /// Encodes an image to a raw JPEG 2000 codestream. + /// The image. + /// The coding options. + /// The codestream bytes. + public static byte[] Encode(J2kImage image, J2kOptions options) + { + var tw = options.TileWidth == 0 ? image.Xsiz - options.TileXOffset : options.TileWidth; + var th = options.TileHeight == 0 ? image.Ysiz - options.TileYOffset : options.TileHeight; + var tilesX = (image.Xsiz - options.TileXOffset + tw - 1) / tw; + var tilesY = (image.Ysiz - options.TileYOffset + th - 1) / th; + + var tileParts = new List<(int Tile, byte[] Bytes)>(); + var ppmChunks = new List(); + for (var t = 0; t < tilesX * tilesY; t++) + { + var p = options.TileOverrides.TryGetValue(t, out var o) ? o : options; + var tx0 = Math.Max(options.TileXOffset + ((t % tilesX) * tw), image.XOsiz); + var tx1 = Math.Min(options.TileXOffset + (((t % tilesX) + 1) * tw), image.Xsiz); + var ty0 = Math.Max(options.TileYOffset + ((t / tilesX) * th), image.YOsiz); + var ty1 = Math.Min(options.TileYOffset + (((t / tilesX) + 1) * th), image.Ysiz); + var packets = BuildTilePackets(image, p, tx0, ty0, tx1, ty1); + AppendTileParts(t, options, p, !ReferenceEquals(p, options), packets, tileParts, ppmChunks, image); + } + + var output = new List(); + WriteMarker(output, 0xFF4F); + WriteSiz(output, image, options, tw, th); + WriteCodingMarkers(output, image, options, tileParts.Count); + if (options.HeaderMode == J2kHeaderMode.Ppm) + { + WritePpm(output, ppmChunks, options.PackedChunkSize); + } + + if (options.ExtraMarkers) + { + WriteExtraMainMarkers(output, image, tileParts); + } + + for (var i = 0; i < tileParts.Count; i++) + { + var bytes = tileParts[i].Bytes; + if (options.ZeroPsotOnLast && i == tileParts.Count - 1) + { + bytes = (byte[])bytes.Clone(); + bytes[6] = bytes[7] = bytes[8] = bytes[9] = 0; + } + + output.AddRange(bytes); + } + + if (!options.OmitEoc) + { + WriteMarker(output, 0xFFD9); + } + + return [.. output]; + } + + /// Builds a deterministic test image. + /// The width. + /// The height. + /// The component count. + /// The bit depth. + /// The pseudo-random seed. + /// The noise amplitude (0 = smooth). + /// Whether the samples are signed. + /// The image. + public static J2kImage MakeImage(int width, int height, int components, int depth = 8, int seed = 1, int noise = 24, bool signed = false) + { + var image = new J2kImage { Xsiz = width, Ysiz = height, Components = new J2kComponent[components] }; + var rng = new Random(seed); + var max = (1 << depth) - 1; + for (var c = 0; c < components; c++) + { + var comp = new J2kComponent { Depth = depth, Signed = signed, Samples = new int[width * height] }; + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + var smooth = ((x * 3) + (y * 2) + (c * 40)) % (max + 1); + if (((x / 7) + (y / 5)) % 4 == 0) + { + smooth = max - smooth; + } + + var v = Math.Clamp(smooth + (noise == 0 ? 0 : rng.Next(-noise, noise + 1)), 0, max); + comp.Samples[(y * width) + x] = signed ? v - (1 << (depth - 1)) : v; + } + } + + image.Components[c] = comp; + } + + return image; + } + + /// Computes the 8-bit value the decoder is expected to produce for a component sample. + /// The natural sample value. + /// The bit depth. + /// Whether the component is signed. + /// The expected byte. + public static byte ExpectedByte(int sample, int depth, bool signed) + { + var unsigned = signed ? sample + (1 << (depth - 1)) : sample; + if (depth == 8) + { + return (byte)unsigned; + } + + long max = (1L << depth) - 1; + return (byte)Math.Min(255L, ((unsigned * 255L) + (max / 2)) / max); + } + + // ------------------------------------------------------------------------------------------ + // Tile coding + // ------------------------------------------------------------------------------------------ + + private static int LevelsOf(J2kOptions o, int c) => o.ComponentLevels is { } l ? l[c] : o.Levels; + + private static (int Ppx, int Ppy) PrecinctOf(J2kOptions o, int r) + { + if (o.Precincts is not { Length: > 0 } p) + { + return (15, 15); + } + + return p[Math.Min(r, p.Length - 1)]; + } + + private static long CeilDiv(long a, long b) => (a + b - 1) / b; + + private static long CeilPow2(long a, int s) => (a + (1L << s) - 1) >> s; + + private static List BuildTilePackets(J2kImage image, J2kOptions o, int tx0, int ty0, int tx1, int ty1) + { + var n = image.Components.Length; + var planes = new int[n][]; + var dplanes = new double[n][]; + var dims = new (int X0, int Y0, int W, int H)[n]; + for (var c = 0; c < n; c++) + { + var comp = image.Components[c]; + var x0 = (int)CeilDiv(tx0, comp.Dx); + var y0 = (int)CeilDiv(ty0, comp.Dy); + var x1 = (int)CeilDiv(tx1, comp.Dx); + var y1 = (int)CeilDiv(ty1, comp.Dy); + dims[c] = (x0, y0, x1 - x0, y1 - y0); + planes[c] = new int[Math.Max(0, (x1 - x0) * (y1 - y0))]; + var shift = comp.Signed ? 0 : 1 << (comp.Depth - 1); + for (var y = y0; y < y1; y++) + { + for (var x = x0; x < x1; x++) + { + planes[c][((y - y0) * (x1 - x0)) + x - x0] = comp.Samples[((y - image.CompY0(c)) * image.CompWidth(c)) + x - image.CompX0(c)] - shift; + } + } + } + + if (o.Mct) + { + ForwardMct(o, planes, dplanes); + } + + if (!o.Reversible) + { + for (var c = 0; c < n; c++) + { + dplanes[c] ??= planes[c].Select(v => (double)v).ToArray(); + } + } + + var comps = new ETileComp[n]; + var blockCounter = 0; + for (var c = 0; c < n; c++) + { + comps[c] = BuildTileComp(image, o, c, dims[c], planes[c], dplanes[c], ref blockCounter); + } + + return BuildPackets(image, o, comps, tx0, ty0, tx1, ty1); + } + + private static void ForwardMct(J2kOptions o, int[][] planes, double[][] dplanes) + { + var len = planes[0].Length; + if (o.Reversible) + { + for (var i = 0; i < len; i++) + { + int r = planes[0][i], g = planes[1][i], b = planes[2][i]; + planes[0][i] = (r + (2 * g) + b) >> 2; + planes[1][i] = b - g; + planes[2][i] = r - g; + } + + return; + } + + var y = new double[len]; + var cb = new double[len]; + var cr = new double[len]; + for (var i = 0; i < len; i++) + { + double r = planes[0][i], g = planes[1][i], b = planes[2][i]; + y[i] = (0.299 * r) + (0.587 * g) + (0.114 * b); + cb[i] = (-0.16875 * r) - (0.33126 * g) + (0.5 * b); + cr[i] = (0.5 * r) - (0.41869 * g) - (0.08131 * b); + } + + dplanes[0] = y; + dplanes[1] = cb; + dplanes[2] = cr; + } + + private static ETileComp BuildTileComp(J2kImage image, J2kOptions o, int c, (int X0, int Y0, int W, int H) d, int[] iplane, double[]? fplane, ref int blockCounter) + { + var levels = LevelsOf(o, c); + var rects = new (long X0, long Y0, long X1, long Y1)[levels + 1]; + for (var r = 0; r <= levels; r++) + { + var s = levels - r; + rects[r] = (CeilPow2(d.X0, s), CeilPow2(d.Y0, s), CeilPow2(d.X0 + d.W, s), CeilPow2(d.Y0 + d.H, s)); + } + + if (o.Reversible) + { + ForwardDwt(iplane, d.W, rects, Forward53); + } + else + { + ForwardDwt(fplane!, d.W, rects, Forward97); + } + + var res = new ERes[levels + 1]; + for (var r = 0; r <= levels; r++) + { + res[r] = BuildRes(image, o, c, levels, r, rects, d, iplane, fplane, ref blockCounter); + } + + return new ETileComp { Res = res, Levels = levels }; + } + + private static EBand MakeBand(J2kImage image, J2kOptions o, int c, int levels, int r, int orient, (int X0, int Y0, int W, int H) d, (long X0, long Y0, long X1, long Y1)[] rects) + { + long x0, y0, x1, y1; + int offX = 0, offY = 0; + if (r == 0) + { + x0 = rects[0].X0; + y0 = rects[0].Y0; + x1 = rects[0].X1; + y1 = rects[0].Y1; + } + else + { + var nb = levels - r + 1; + var xob = orient is 1 or 3 ? 1L : 0L; + var yob = orient is 2 or 3 ? 1L : 0L; + var half = 1L << (nb - 1); + x0 = CeilPow2(d.X0 - (xob * half), nb); + y0 = CeilPow2(d.Y0 - (yob * half), nb); + x1 = CeilPow2(d.X0 + d.W - (xob * half), nb); + y1 = CeilPow2(d.Y0 + d.H - (yob * half), nb); + offX = xob == 1 ? (int)(rects[r - 1].X1 - rects[r - 1].X0) : 0; + offY = yob == 1 ? (int)(rects[r - 1].Y1 - rects[r - 1].Y0) : 0; + } + + var (eps, mant) = QuantFor(image, o, c, levels, r, orient); + var gain = orient switch { 0 => 0, 3 => 2, _ => 1 }; + var depth = image.Components[c].Depth; + var step = o.Reversible ? 1.0 : Math.Pow(2, depth + gain - eps) * (1.0 + (mant / 2048.0)); + return new EBand { Orient = orient, X0 = x0, Y0 = y0, X1 = x1, Y1 = y1, Eps = eps, Mant = mant, Mb = o.GuardBits + eps - 1, Step = step, OffX = offX, OffY = offY }; + } + + /// Gets the quantization exponent and mantissa of a band. + private static (int Eps, int Mant) QuantFor(J2kImage image, J2kOptions o, int c, int levels, int r, int orient) + { + var depth = image.Components[c].Depth; + var gain = orient switch { 0 => 0, 3 => 2, _ => 1 }; + var bandIndex = r == 0 ? 0 : (3 * (r - 1)) + orient; + if (o.Reversible) + { + return (depth + gain + 1 + (o.Mct ? 1 : 0), 0); + } + + var mant = o.QuantMantissa ? ((bandIndex * 371) + 97) % 2048 : 0; + if (o.QuantStyle == 1) + { + var eps0 = depth + 2 + levels + 1; + return (eps0 - (r == 0 ? 0 : r - 1), o.QuantMantissa ? 0x155 : 0); + } + + return (depth + gain + 1, mant); + } + + private static ERes BuildRes(J2kImage image, J2kOptions o, int c, int levels, int r, (long X0, long Y0, long X1, long Y1)[] rects, (int X0, int Y0, int W, int H) d, int[] iplane, double[]? fplane, ref int blockCounter) + { + var rc = rects[r]; + var (ppx, ppy) = PrecinctOf(o, r); + var pw = rc.X1 > rc.X0 ? (int)(CeilPow2(rc.X1, ppx) - (rc.X0 >> ppx)) : 0; + var ph = rc.Y1 > rc.Y0 ? (int)(CeilPow2(rc.Y1, ppy) - (rc.Y0 >> ppy)) : 0; + var orients = r == 0 ? new[] { 0 } : new[] { 1, 2, 3 }; + var bands = orients.Select(or => MakeBand(image, o, c, levels, r, or, d, rects)).ToArray(); + var precincts = new EPrecinct[pw * ph]; + for (var j = 0; j < ph; j++) + { + for (var i = 0; i < pw; i++) + { + var parts = new EPrecBand[bands.Length]; + for (var b = 0; b < bands.Length; b++) + { + parts[b] = BuildPrecBand(o, c, r, bands[b], ppx, ppy, (rc.X0 >> ppx) + i, (rc.Y0 >> ppy) + j, d.W, iplane, fplane, ref blockCounter); + } + + precincts[(j * pw) + i] = new EPrecinct { Bands = parts }; + } + } + + return new ERes { X0 = rc.X0, Y0 = rc.Y0, X1 = rc.X1, Y1 = rc.Y1, PPx = ppx, PPy = ppy, Pw = pw, Ph = ph, Precincts = precincts }; + } + + private static bool InRoi(J2kOptions o, int c, long u, long v) + { + if (o.RoiShift == 0 || (o.RoiComponents is { } rc && !rc.Contains(c))) + { + return false; + } + + return (((u >> 1) + (v >> 1)) & 1) == 0; + } + + private static EPrecBand BuildPrecBand(J2kOptions o, int c, int r, EBand band, int ppx, int ppy, long pxAbs, long pyAbs, int stride, int[] iplane, double[]? fplane, ref int blockCounter) + { + var sx = r == 0 ? ppx : ppx - 1; + var sy = r == 0 ? ppy : ppy - 1; + var x0 = Math.Max(pxAbs << sx, band.X0); + var x1 = Math.Min((pxAbs + 1) << sx, band.X1); + var y0 = Math.Max(pyAbs << sy, band.Y0); + var y1 = Math.Min((pyAbs + 1) << sy, band.Y1); + if (x1 <= x0 || y1 <= y0) + { + return new EPrecBand { Band = band }; + } + + var xcb = Math.Min(o.CodeBlockWidthExp, sx); + var ycb = Math.Min(o.CodeBlockHeightExp, sy); + var cbx0 = x0 >> xcb; + var cby0 = y0 >> ycb; + var cw = (int)(CeilPow2(x1, xcb) - cbx0); + var ch = (int)(CeilPow2(y1, ycb) - cby0); + var blocks = new EncodedBlock[cw * ch]; + var tier1 = new Tier1Encoder(); + var totalPlanes = band.Mb + (o.RoiComponents is { } roiComps && !roiComps.Contains(c) ? 0 : o.RoiShift); + for (var j = 0; j < ch; j++) + { + for (var i = 0; i < cw; i++) + { + var bx0 = Math.Max(x0, (cbx0 + i) << xcb); + var bx1 = Math.Min(x1, (cbx0 + i + 1) << xcb); + var by0 = Math.Max(y0, (cby0 + j) << ycb); + var by1 = Math.Min(y1, (cby0 + j + 1) << ycb); + var w = (int)(bx1 - bx0); + var h = (int)(by1 - by0); + var values = new int[w * h]; + for (var yy = 0; yy < h; yy++) + { + for (var xx = 0; xx < w; xx++) + { + var u = bx0 + xx; + var v = by0 + yy; + var pos = ((band.OffY + (int)(v - band.Y0)) * stride) + band.OffX + (int)(u - band.X0); + var q = o.Reversible ? iplane[pos] : Quantize(fplane![pos], band.Step); + if (q != 0 && InRoi(o, c, u, v)) + { + q = q < 0 ? -((-q) << o.RoiShift) : q << o.RoiShift; + } + + values[(yy * w) + xx] = q; + } + } + + blocks[(j * cw) + i] = tier1.Encode(values, w, h, band.Orient, o.CodeBlockStyle, totalPlanes); + } + } + + var cum = new int[cw * ch][]; + for (var k = 0; k < blocks.Length; k++) + { + cum[k] = LayerSplit(blocks[k].Passes, o.Layers, blockCounter++); + } + + var firstLayer = new int[blocks.Length]; + var zbp = new int[blocks.Length]; + for (var k = 0; k < blocks.Length; k++) + { + firstLayer[k] = blocks[k].Passes == 0 ? int.MaxValue : Array.FindIndex(cum[k], v => v > 0); + zbp[k] = blocks[k].Passes == 0 ? int.MaxValue : blocks[k].ZeroBitPlanes; + } + + return new EPrecBand + { + Band = band, + Cw = cw, + Ch = ch, + Blocks = blocks, + Cum = cum, + Included = new bool[blocks.Length], + Lblock = Enumerable.Repeat(3, blocks.Length).ToArray(), + Inclusion = new TagTreeEncoder(cw, ch, firstLayer), + ZeroBits = new TagTreeEncoder(cw, ch, zbp), + }; + } + + private static int Quantize(double value, double step) + { + var q = (int)Math.Floor(Math.Abs(value) / step); + return value < 0 ? -q : q; + } + + private static int[] LayerSplit(int total, int layers, int counter) + { + var cum = new int[layers]; + for (var l = 0; l < layers; l++) + { + if (l == layers - 1) + { + cum[l] = total; + continue; + } + + cum[l] = (counter % 3) switch + { + 0 => total * (l + 1) / layers, + 1 => total * l / layers, + _ => 0, + }; + } + + return cum; + } + + // ------------------------------------------------------------------------------------------ + // Packets + // ------------------------------------------------------------------------------------------ + + private static List BuildPackets(J2kImage image, J2kOptions o, ETileComp[] comps, int tx0, int ty0, int tx1, int ty1) + { + var sequence = new List<(int L, int R, int C, int K)>(); + var sent = new HashSet<(int, int, int, int)>(); + var volumes = o.Poc is { Count: > 0 } poc + ? poc + : [new J2kPoc(0, 0, o.Layers, comps.Max(t => t.Levels) + 1, comps.Length, o.Progression)]; + foreach (var v in volumes) + { + foreach (var id in Enumerate(image, o, comps, v, tx0, ty0, tx1, ty1)) + { + if (sent.Add(id)) + { + sequence.Add(id); + } + } + } + + var packets = new List(); + var sopCounter = 0; + foreach (var (l, r, c, k) in sequence) + { + packets.Add(WritePacket(o, comps[c].Res[r].Precincts[k], l, sopCounter++)); + } + + return packets; + } + + private static IEnumerable<(int L, int R, int C, int K)> Enumerate(J2kImage image, J2kOptions o, ETileComp[] comps, J2kPoc v, int tx0, int ty0, int tx1, int ty1) + { + var layerEnd = Math.Min(v.LayerEnd, o.Layers); + var compEnd = Math.Min(v.CompEnd, comps.Length); + var resEnd = v.ResEnd; + IEnumerable<(int, int, int, int)> Precincts(int l, int r, int c) + { + if (r > comps[c].Levels) + { + yield break; + } + + for (var k = 0; k < comps[c].Res[r].Precincts.Length; k++) + { + yield return (l, r, c, k); + } + } + + switch (v.Order) + { + case 0: + for (var l = 0; l < layerEnd; l++) + { + for (var r = v.ResStart; r < resEnd; r++) + { + for (var c = v.CompStart; c < compEnd; c++) + { + foreach (var id in Precincts(l, r, c)) + { + yield return id; + } + } + } + } + + break; + case 1: + for (var r = v.ResStart; r < resEnd; r++) + { + for (var l = 0; l < layerEnd; l++) + { + for (var c = v.CompStart; c < compEnd; c++) + { + foreach (var id in Precincts(l, r, c)) + { + yield return id; + } + } + } + } + + break; + default: + foreach (var id in EnumeratePositional(image, comps, v, layerEnd, resEnd, compEnd, tx0, ty0, tx1, ty1)) + { + yield return id; + } + + break; + } + } + + private static bool PositionHit(J2kImage image, ETileComp[] comps, int c, int r, int x, int y, int tx0, int ty0, out int k) + { + k = 0; + var t = comps[c]; + if (r > t.Levels) + { + return false; + } + + var res = t.Res[r]; + if (res.Pw == 0 || res.Ph == 0) + { + return false; + } + + var nl = t.Levels; + var dxr = image.Components[c].Dx; + var dyr = image.Components[c].Dy; + var yOk = (y % ((long)dyr << (res.PPy + nl - r))) == 0 + || (y == ty0 && ((res.Y0 << (nl - r)) % (1L << (res.PPy + nl - r))) != 0); + var xOk = (x % ((long)dxr << (res.PPx + nl - r))) == 0 + || (x == tx0 && ((res.X0 << (nl - r)) % (1L << (res.PPx + nl - r))) != 0); + if (!xOk || !yOk) + { + return false; + } + + var px = (CeilDiv(x, (long)dxr << (nl - r)) >> res.PPx) - (res.X0 >> res.PPx); + var py = (CeilDiv(y, (long)dyr << (nl - r)) >> res.PPy) - (res.Y0 >> res.PPy); + k = (int)(px + (res.Pw * py)); + return px >= 0 && py >= 0 && px < res.Pw && py < res.Ph; + } + + private static IEnumerable<(int L, int R, int C, int K)> EnumeratePositional(J2kImage image, ETileComp[] comps, J2kPoc v, int layerEnd, int resEnd, int compEnd, int tx0, int ty0, int tx1, int ty1) + { + if (v.Order == 2) + { + for (var r = v.ResStart; r < resEnd; r++) + { + for (var y = ty0; y < ty1; y++) + { + for (var x = tx0; x < tx1; x++) + { + for (var c = v.CompStart; c < compEnd; c++) + { + if (PositionHit(image, comps, c, r, x, y, tx0, ty0, out var k)) + { + for (var l = 0; l < layerEnd; l++) + { + yield return (l, r, c, k); + } + } + } + } + } + } + } + else if (v.Order == 3) + { + for (var y = ty0; y < ty1; y++) + { + for (var x = tx0; x < tx1; x++) + { + for (var c = v.CompStart; c < compEnd; c++) + { + for (var r = v.ResStart; r < resEnd; r++) + { + if (PositionHit(image, comps, c, r, x, y, tx0, ty0, out var k)) + { + for (var l = 0; l < layerEnd; l++) + { + yield return (l, r, c, k); + } + } + } + } + } + } + } + else + { + for (var c = v.CompStart; c < compEnd; c++) + { + for (var y = ty0; y < ty1; y++) + { + for (var x = tx0; x < tx1; x++) + { + for (var r = v.ResStart; r < resEnd; r++) + { + if (PositionHit(image, comps, c, r, x, y, tx0, ty0, out var k)) + { + for (var l = 0; l < layerEnd; l++) + { + yield return (l, r, c, k); + } + } + } + } + } + } + } + } + + private static int PassesInLayer(EPrecBand pb, int block, int layer) => + pb.Cum[block][layer] - (layer == 0 ? 0 : pb.Cum[block][layer - 1]); + + private static Packet WritePacket(J2kOptions o, EPrecinct precinct, int layer, int sequence) + { + var bw = new HeaderBitWriter(); + var body = new List(); + var any = precinct.Bands.Any(pb => Enumerable.Range(0, pb.Blocks.Length).Any(i => PassesInLayer(pb, i, layer) > 0)); + if (!any) + { + bw.Bit(0); + } + else + { + bw.Bit(1); + foreach (var pb in precinct.Bands) + { + for (var i = 0; i < pb.Blocks.Length; i++) + { + WriteBlockContribution(o, pb, i, layer, bw, body); + } + } + } + + var header = new List(bw.Finish()); + if (o.Eph) + { + header.Add(0xFF); + header.Add(0x92); + } + + byte[] sop = o.Sop ? [0xFF, 0x91, 0x00, 0x04, (byte)(sequence >> 8), (byte)sequence] : []; + return new Packet { Sop = sop, Header = [.. header], Data = [.. body] }; + } + + private static void WriteBlockContribution(J2kOptions o, EPrecBand pb, int i, int layer, HeaderBitWriter bw, List body) + { + var block = pb.Blocks[i]; + var n = PassesInLayer(pb, i, layer); + var cx = i % pb.Cw; + var cy = i / pb.Cw; + if (pb.Included[i]) + { + bw.Bit(n > 0 ? 1 : 0); + } + else + { + pb.Inclusion!.Encode(bw, cx, cy, layer + 1); + } + + if (n == 0) + { + return; + } + + if (!pb.Included[i]) + { + pb.Included[i] = true; + pb.ZeroBits!.Encode(bw, cx, cy, block.ZeroBitPlanes + 1); + } + + WritePassCount(bw, n); + var first = layer == 0 ? 0 : pb.Cum[i][layer - 1]; + var pieces = Pieces(block, first, first + n); + var increment = 0; + foreach (var (passes, data) in pieces) + { + var need = (32 - int.LeadingZeroCount(Math.Max(1, data.Length))) - (31 - int.LeadingZeroCount(passes)); + increment = Math.Max(increment, need - pb.Lblock[i]); + } + + for (var k = 0; k < increment; k++) + { + bw.Bit(1); + } + + bw.Bit(0); + pb.Lblock[i] += increment; + foreach (var (passes, data) in pieces) + { + bw.Bits(data.Length, pb.Lblock[i] + (31 - int.LeadingZeroCount(passes))); + body.AddRange(data); + } + } + + private static List<(int Passes, byte[] Data)> Pieces(EncodedBlock block, int firstPass, int endPass) + { + var result = new List<(int, byte[])>(); + var start = 0; + foreach (var (segPasses, data) in block.Segments) + { + var s0 = start; + var s1 = start + segPasses; + start = s1; + var a = Math.Max(firstPass, s0); + var b = Math.Min(endPass, s1); + if (b <= a) + { + continue; + } + + var from = data.Length * (a - s0) / segPasses; + var to = data.Length * (b - s0) / segPasses; + result.Add((b - a, data[from..to])); + } + + return result; + } + + private static void WritePassCount(HeaderBitWriter bw, int n) + { + if (n == 1) + { + bw.Bit(0); + } + else if (n == 2) + { + bw.Bits(2, 2); + } + else if (n <= 5) + { + bw.Bits(3, 2); + bw.Bits(n - 3, 2); + } + else if (n <= 36) + { + bw.Bits(0xF, 4); + bw.Bits(n - 6, 5); + } + else + { + bw.Bits(0x1FF, 9); + bw.Bits(n - 37, 7); + } + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000ValidationTests.cs b/test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000ValidationTests.cs new file mode 100644 index 00000000..9a9a92cf --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Codecs/Jpeg2000ValidationTests.cs @@ -0,0 +1,531 @@ +// cspell:ignore ftyp ihdr colr jp2h jp2c ppm ppt tlm plm plt crg poc rgn coc qcc qcd sot sod eoc siz csiz xrsiz yrsiz xtsiz ytsiz xosiz xtosiz bpcc unkc ipr zppm zppt tpsot lsot isot psot +using System.Text; +using DemaConsulting.CanvasNet.Codecs; + +namespace DemaConsulting.CanvasNet.Tests.Codecs; + +/// +/// Fail-closed validation tests of the against ISO/IEC 15444-1 (ITU-T T.800) +/// Annex A (codestream markers) and Annex I (JP2 boxes): each test hand-patches a small valid stream and asserts +/// the documented outcome, either an exception of a documented type or, for a deliberate leniency, success. +/// +public class Jpeg2000ValidationTests +{ + private static readonly byte[] Marker64 = [0xFF, 0x64]; + + // ------------------------------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------------------------------ + + private static J2kImage Img(int comps) => Jpeg2000TestEncoder.MakeImage(8, 8, comps, 8, 1, 24, false); + + private static byte[] Stream(int comps = 1, int levels = 2, Action? configure = null) + { + var options = new J2kOptions { Levels = levels }; + configure?.Invoke(options); + return Jpeg2000TestEncoder.Encode(Img(comps), options); + } + + private static int FindMarker(byte[] data, int marker) + { + for (var i = 2; i < data.Length - 1; i++) + { + if (data[i] == (marker >> 8) && data[i + 1] == (marker & 0xFF)) + { + return i; + } + } + + throw new InvalidOperationException("marker not found in test stream."); + } + + private static byte[] Replace(byte[] data, int offset, params byte[] bytes) + { + var copy = (byte[])data.Clone(); + Array.Copy(bytes, 0, copy, offset, bytes.Length); + return copy; + } + + private static byte[] InsertAt(byte[] data, int offset, byte[] extra) => + [.. data[..offset], .. extra, .. data[offset..]]; + + private static byte[] Segment(int marker, params byte[] payload) => + [(byte)(marker >> 8), (byte)marker, (byte)((payload.Length + 2) >> 8), (byte)(payload.Length + 2), .. payload]; + + private static int SegmentLength(byte[] data, int at) => 2 + ((data[at + 2] << 8) | data[at + 3]); + + /// Inserts bytes directly after the SIZ segment, which places them in the main header. + private static byte[] InsertInMainHeader(byte[] data, byte[] extra) + { + var siz = FindMarker(data, 0xFF51); + return InsertAt(data, siz + SegmentLength(data, siz), extra); + } + + /// Inserts bytes directly after the first SOT segment (in the tile-part header) and grows Psot to match. + private static byte[] InsertInTileHeader(byte[] data, byte[] extra) + { + var sot = FindMarker(data, 0xFF90); + var psot = (data[sot + 6] << 24) | (data[sot + 7] << 16) | (data[sot + 8] << 8) | data[sot + 9]; + Assert.NotEqual(0, psot); + psot += extra.Length; + var copy = Replace(data, sot + 6, (byte)(psot >> 24), (byte)(psot >> 16), (byte)(psot >> 8), (byte)psot); + return InsertAt(copy, sot + 12, extra); + } + + private static void AssertRejected(byte[] data, string? cause = null) + where T : Exception + { + var ex = Assert.Throws(() => Jpeg2000Codec.Decode(data)); + Assert.Null(ex.InnerException); + if (cause is not null) + { + Assert.Contains(cause, ex.Message); + } + } + + private static void AssertDecodes(byte[] data, byte[] expectedSamples) => + Assert.Equal(expectedSamples, Jpeg2000Codec.Decode(data).ColorSamples); + + // ------------------------------------------------------------------------------------------ + // JP2 container (ITU-T T.800 Annex I) + // ------------------------------------------------------------------------------------------ + + private static byte[] Box(string type, params byte[][] parts) + { + var content = parts.SelectMany(p => p).ToArray(); + var length = content.Length + 8; + return [(byte)(length >> 24), (byte)(length >> 16), (byte)(length >> 8), (byte)length, .. Encoding.ASCII.GetBytes(type), .. content]; + } + + private static byte[] Content(byte[] box) => box[8..]; + + /// The pieces of a valid three-component JP2 file, so each test can recombine them. + private static byte[] Compose(params byte[][] boxes) => [.. boxes.SelectMany(b => b)]; + + private static byte[] Header(params byte[][] children) => Box("jp2h", children); + + private sealed record Jp2Pieces(byte[] Signature, byte[] FileType, byte[] Ihdr, byte[] Colr, byte[] Codestream) + { + public byte[] CodestreamBox => Box("jp2c", Codestream); + + public byte[] Valid() => Compose(Signature, FileType, Header(Ihdr, Colr), CodestreamBox); + } + + private static Jp2Pieces Pieces() + { + var image = Img(3); + var codestream = Jpeg2000TestEncoder.Encode(image, new J2kOptions { Levels = 1 }); + var jp2 = Jpeg2000TestEncoder.WrapJp2(image, codestream, new J2kJp2Options()); + var boxes = new List(); + for (var pos = 0; pos < jp2.Length;) + { + var length = (jp2[pos] << 24) | (jp2[pos + 1] << 16) | (jp2[pos + 2] << 8) | jp2[pos + 3]; + boxes.Add(jp2[pos..(pos + length)]); + pos += length; + } + + var jp2h = boxes.Single(b => Encoding.ASCII.GetString(b, 4, 4) == "jp2h"); + var ihdrLength = (jp2h[8] << 24) | (jp2h[9] << 16) | (jp2h[10] << 8) | jp2h[11]; + return new Jp2Pieces(boxes[0], boxes[1], jp2h[8..(8 + ihdrLength)], jp2h[(8 + ihdrLength)..], codestream); + } + + private sealed class NonSeekableStream(byte[] data) : MemoryStream(data) + { + public override bool CanSeek => false; + } + + /// Asserts that Decode, GetInfo (seekable) and GetInfo (non-seekable) all reject the file with the same cause. + private static void AssertJp2Rejected(byte[] file, string cause) + { + AssertRejected(file, cause); + var seekable = Assert.Throws(() => Jpeg2000Codec.GetInfo(new MemoryStream(file))); + Assert.Contains(cause, seekable.Message); + var forward = Assert.Throws(() => Jpeg2000Codec.GetInfo(new NonSeekableStream(file))); + Assert.Contains(cause, forward.Message); + } + + /// Asserts that Decode and both GetInfo paths accept the file and report the 8 x 8 image. + private static void AssertJp2Accepted(byte[] file) + { + var image = Jpeg2000Codec.Decode(file); + Assert.Equal(8, image.Width); + Assert.Equal(8, image.Height); + Assert.Equal(8, Jpeg2000Codec.GetInfo(new MemoryStream(file)).Width); + Assert.Equal(8, Jpeg2000Codec.GetInfo(new NonSeekableStream(file)).Height); + } + + /// Tests that the valid baseline file used by the other container tests is accepted. + [Fact] + public void Jpeg2000Codec_Jp2Baseline_IsAccepted() => AssertJp2Accepted(Pieces().Valid()); + + /// Tests that a JP2 file without the file type box is rejected by Decode and GetInfo (T.800 I.5.2). + [Fact] + public void Jpeg2000Codec_Jp2MissingFileType_IsRejected() + { + var p = Pieces(); + AssertJp2Rejected(Compose(p.Signature, Header(p.Ihdr, p.Colr), p.CodestreamBox), "file type box"); + } + + /// Tests that a file type box that is not the second box is rejected. + [Fact] + public void Jpeg2000Codec_Jp2FileTypeNotSecond_IsRejected() + { + var p = Pieces(); + AssertJp2Rejected(Compose(p.Signature, Header(p.Ihdr, p.Colr), p.FileType, p.CodestreamBox), "file type box"); + } + + /// Tests the file type box length rule: at least 8 content bytes and a multiple of 4. + [Theory] + [InlineData(0, false)] + [InlineData(4, false)] + [InlineData(5, false)] + [InlineData(9, false)] + [InlineData(8, true)] + [InlineData(12, true)] + [InlineData(16, true)] + public void Jpeg2000Codec_Jp2FileTypeLength_IsChecked(int contentLength, bool accepted) + { + var p = Pieces(); + var content = new byte[contentLength]; + Encoding.ASCII.GetBytes("jp2 ").AsSpan(0, Math.Min(4, contentLength)).CopyTo(content); + var file = Compose(p.Signature, Box("ftyp", content), Header(p.Ihdr, p.Colr), p.CodestreamBox); + if (accepted) + { + AssertJp2Accepted(file); + } + else + { + AssertJp2Rejected(file, "file type box"); + } + } + + /// Tests that a JP2 file with no header box is rejected (T.800 I.5.3). + [Fact] + public void Jpeg2000Codec_Jp2MissingHeaderBox_IsRejected() + { + var p = Pieces(); + AssertJp2Rejected(Compose(p.Signature, p.FileType, p.CodestreamBox), "header box must precede"); + } + + /// Tests that a header box after the codestream box is rejected. + [Fact] + public void Jpeg2000Codec_Jp2HeaderAfterCodestream_IsRejected() + { + var p = Pieces(); + AssertJp2Rejected(Compose(p.Signature, p.FileType, p.CodestreamBox, Header(p.Ihdr, p.Colr)), "header box must precede"); + } + + /// Tests that a header box without an image header box is rejected. + [Fact] + public void Jpeg2000Codec_Jp2HeaderWithoutIhdr_IsRejected() + { + var p = Pieces(); + AssertJp2Rejected(Compose(p.Signature, p.FileType, Header(p.Colr), p.CodestreamBox), "no image header box"); + AssertJp2Rejected(Compose(p.Signature, p.FileType, Header(), p.CodestreamBox), "no image header box"); + } + + /// Tests that an image header box must be exactly 14 bytes long. + [Theory] + [InlineData(13)] + [InlineData(15)] + public void Jpeg2000Codec_Jp2IhdrLength_IsChecked(int contentLength) + { + var p = Pieces(); + var content = new byte[contentLength]; + Content(p.Ihdr).AsSpan(0, Math.Min(contentLength, 14)).CopyTo(content); + AssertJp2Rejected(Compose(p.Signature, p.FileType, Header(Box("ihdr", content), p.Colr), p.CodestreamBox), "image header box length"); + } + + /// Tests that an image header box whose height or width differs from SIZ is rejected. + [Theory] + [InlineData(3)] + [InlineData(7)] + public void Jpeg2000Codec_Jp2IhdrSizeMismatch_IsRejected(int contentOffset) + { + var p = Pieces(); + var content = Content(p.Ihdr); + content[contentOffset]++; + AssertJp2Rejected(Compose(p.Signature, p.FileType, Header(Box("ihdr", content), p.Colr), p.CodestreamBox), "does not match"); + } + + /// Tests the documented JP2 leniencies: each of these files decodes, as it does with OpenJPEG. + [Theory] + [InlineData("no colr")] + [InlineData("colr before ihdr")] + [InlineData("other brand")] + [InlineData("NC, BPC, C, UnkC and IPR differ")] + [InlineData("second ihdr ignored")] + [InlineData("second jp2h without ihdr")] + public void Jpeg2000Codec_Jp2Leniency_IsAccepted(string variant) + { + var p = Pieces(); + var ihdr = Content(p.Ihdr); + var file = variant switch + { + "no colr" => Compose(p.Signature, p.FileType, Header(p.Ihdr), p.CodestreamBox), + "colr before ihdr" => Compose(p.Signature, p.FileType, Header(p.Colr, p.Ihdr), p.CodestreamBox), + "other brand" => Compose(p.Signature, Box("ftyp", Encoding.ASCII.GetBytes("abcd"), new byte[4], Encoding.ASCII.GetBytes("abcd")), Header(p.Ihdr, p.Colr), p.CodestreamBox), + "NC, BPC, C, UnkC and IPR differ" => Compose( + p.Signature, p.FileType, Header(Box("ihdr", ihdr[..8], [0, 9, 3, 6, 2, 1]), p.Colr), p.CodestreamBox), + "second ihdr ignored" => Compose( + p.Signature, p.FileType, Header(p.Ihdr, p.Colr, Box("ihdr", new byte[14])), p.CodestreamBox), + "second jp2h without ihdr" => Compose(p.Signature, p.FileType, p.Valid()[(p.Signature.Length + p.FileType.Length)..^p.CodestreamBox.Length], Header(), p.CodestreamBox), + _ => throw new ArgumentOutOfRangeException(nameof(variant)), + }; + AssertJp2Accepted(file); + } + + // ------------------------------------------------------------------------------------------ + // Codestream marker placement and ordering (T.800 A.2, A.3) + // ------------------------------------------------------------------------------------------ + + /// Tests that SOC, SOD, EOC, a second SIZ and the tile-part-only markers PPT, PLT and SOP are rejected in the main header. + [Theory] + [InlineData(0xFF4F)] + [InlineData(0xFF93)] + [InlineData(0xFFD9)] + [InlineData(0xFF51)] + [InlineData(0xFF61)] + [InlineData(0xFF58)] + [InlineData(0xFF91)] + public void Jpeg2000Codec_Decode_MarkerNotAllowedInMainHeader_IsRejected(int marker) + { + var data = Stream(); + var extra = marker is 0xFF4F or 0xFF93 or 0xFFD9 ? new[] { (byte)(marker >> 8), (byte)marker } : Segment(marker, 0); + AssertRejected(InsertInMainHeader(data, extra)); + } + + /// Tests that the main-header-only markers TLM, PLM and CRG, COM and an unknown marker are skipped in the main header. + [Theory] + [InlineData(0xFF55)] + [InlineData(0xFF57)] + [InlineData(0xFF63)] + [InlineData(0xFF64)] + [InlineData(0xFF70)] + public void Jpeg2000Codec_Decode_SkippedMarkerInMainHeader_IsAccepted(int marker) + { + var data = Stream(); + AssertDecodes(InsertInMainHeader(data, Segment(marker, 0, 1, 2)), Jpeg2000Codec.Decode(data).ColorSamples); + } + + /// Tests that the main-header-only markers and SOP are rejected in a tile-part header. + [Theory] + [InlineData(0xFF50)] + [InlineData(0xFF51)] + [InlineData(0xFF55)] + [InlineData(0xFF57)] + [InlineData(0xFF60)] + [InlineData(0xFF63)] + [InlineData(0xFF91)] + public void Jpeg2000Codec_Decode_MarkerNotAllowedInTilePartHeader_IsRejected(int marker) + { + var data = Stream(); + var rejected = InsertInTileHeader(data, Segment(marker, 0, 1, 2)); + var ex = Assert.ThrowsAny(() => Jpeg2000Codec.Decode(rejected)); + Assert.Null(ex.InnerException); + Assert.Contains(ex.GetType(), new[] { typeof(InvalidDataException), typeof(UnsupportedImageFeatureException) }); + if (marker != 0xFF50) + { + Assert.IsType(ex); + Assert.Contains("tile-part header", ex.Message); + } + } + + /// Tests that PLT, COM and an unknown marker are skipped in a tile-part header. + [Theory] + [InlineData(0xFF58)] + [InlineData(0xFF64)] + [InlineData(0xFF70)] + public void Jpeg2000Codec_Decode_SkippedMarkerInTilePartHeader_IsAccepted(int marker) + { + var data = Stream(); + AssertDecodes(InsertInTileHeader(data, Segment(marker, 0, 1, 2)), Jpeg2000Codec.Decode(data).ColorSamples); + } + + /// Tests that SIZ must directly follow SOC, and that COD and QCD are required in the main header. + [Fact] + public void Jpeg2000Codec_Decode_MarkerOrdering_IsEnforced() + { + var data = Stream(); + AssertRejected(Replace(data, FindMarker(data, 0xFF51), 0xFF, 0x64), "SIZ marker segment must follow SOC"); + AssertRejected(Replace(data, FindMarker(data, 0xFF52), 0xFF, 0x64), "lacks a COD or QCD"); + AssertRejected(Replace(data, FindMarker(data, 0xFF5C), 0xFF, 0x64), "lacks a COD or QCD"); + Assert.Throws(() => Jpeg2000Codec.GetInfo(new MemoryStream(Replace(data, FindMarker(data, 0xFF51), 0xFF, 0x64)))); + } + + /// Tests that a tile-part header must end with SOD: a tile-part without it never decodes. + [Fact] + public void Jpeg2000Codec_Decode_MissingSod_IsRejected() + { + var data = Stream(); + var ex = Assert.ThrowsAny(() => Jpeg2000Codec.Decode(Replace(data, FindMarker(data, 0xFF93), Marker64))); + Assert.Null(ex.InnerException); + Assert.IsType(ex); + } + + /// Tests the documented leniency that bytes after the EOC marker are ignored. + [Fact] + public void Jpeg2000Codec_Decode_BytesAfterEoc_AreIgnored() + { + var data = Stream(); + AssertDecodes([.. data, 0x01, 0x02, 0x03], Jpeg2000Codec.Decode(data).ColorSamples); + } + + // ------------------------------------------------------------------------------------------ + // PPM / PPT index sequencing (T.800 A.7.4, A.7.5) + // ------------------------------------------------------------------------------------------ + + /// Tests that a repeated Zppm index is rejected while a gap in the sequence is tolerated. + [Fact] + public void Jpeg2000Codec_Decode_PpmIndexes_RepeatRejectedGapAccepted() + { + var data = Stream(configure: o => o.HeaderMode = J2kHeaderMode.Ppm); + var expected = Jpeg2000Codec.Decode(data).ColorSamples; + var ppm = FindMarker(data, 0xFF60); + var repeated = InsertAt(data, ppm + SegmentLength(data, ppm), data[ppm..(ppm + SegmentLength(data, ppm))]); + AssertRejected(repeated, "duplicate PPM"); + AssertDecodes(Replace(data, ppm + 4, 3), expected); + } + + /// Tests that a repeated Zppt index is rejected while a gap in the sequence is tolerated. + [Fact] + public void Jpeg2000Codec_Decode_PptIndexes_RepeatRejectedGapAccepted() + { + var data = Stream(configure: o => o.HeaderMode = J2kHeaderMode.Ppt); + var expected = Jpeg2000Codec.Decode(data).ColorSamples; + var ppt = FindMarker(data, 0xFF61); + var segment = data[ppt..(ppt + SegmentLength(data, ppt))]; + AssertRejected(InsertInTileHeader(data, segment), "duplicate PPT"); + AssertDecodes(Replace(data, ppt + 4, 3), expected); + } + + // ------------------------------------------------------------------------------------------ + // Field-level value ranges + // ------------------------------------------------------------------------------------------ + + /// + /// Invalid field values: the kind of stream to build, the marker, the patches ("offset=hex;offset=hex", + /// offsets relative to the first byte of the marker so the segment payload starts at 4) and whether the + /// outcome is rather than . + /// + public static TheoryData InvalidFields => new() + { + // SIZ: Xsiz, Ysiz, XOsiz, YOsiz, XTsiz, YTsiz, XTOsiz, YTOsiz, Csiz, XRsiz, YRsiz, Rsiz. + { "base", 0xFF51, "6=00000000", false }, + { "base", 0xFF51, "10=00000000", false }, + { "base", 0xFF51, "14=00000008", false }, + { "base", 0xFF51, "18=00000008", false }, + { "base", 0xFF51, "22=00000000", false }, + { "base", 0xFF51, "26=00000000", false }, + { "base", 0xFF51, "30=00000001", false }, + { "base", 0xFF51, "34=00000001", false }, + { "base", 0xFF51, "14=00000003;22=00000002", false }, + { "base", 0xFF51, "38=0000", false }, + { "base", 0xFF51, "38=4001", false }, + { "base", 0xFF51, "38=0011", true }, + { "base", 0xFF51, "41=00", false }, + { "base", 0xFF51, "42=00", false }, + { "base", 0xFF51, "4=4000", true }, + { "base", 0xFF51, "4=8000", true }, + { "base", 0xFF51, "40=10", true }, + + // COD: progression order, layers, decomposition levels, code-block exponents, style, transform, MCT. + { "base", 0xFF52, "5=05", false }, + { "base", 0xFF52, "6=0000", false }, + { "base", 0xFF52, "9=21", false }, + { "base", 0xFF52, "10=09", false }, + { "base", 0xFF52, "11=09", false }, + { "base", 0xFF52, "10=05;11=05", false }, + { "base", 0xFF52, "12=40", true }, + { "base", 0xFF52, "12=80", true }, + { "base", 0xFF52, "13=02", true }, + { "base", 0xFF52, "8=02", true }, + + // Precinct exponents: zero is only allowed at the lowest resolution. + { "precinct", 0xFF52, "15=50", false }, + { "precinct", 0xFF52, "16=05", false }, + + // QCD: quantization style 3 does not exist. + { "base", 0xFF5C, "4=43", false }, + + // COC / QCC component index beyond Csiz. + { "coc", 0xFF53, "4=03", false }, + { "coc", 0xFF5D, "4=03", false }, + + // RGN: component index, style, shift. + { "roi", 0xFF5E, "4=01", false }, + { "roi", 0xFF5E, "5=01", true }, + { "roi", 0xFF5E, "6=26", false }, + + // POC: RSpoc >= REpoc, CSpoc >= CEpoc, LYEpoc = 0, progression order. + { "poc", 0xFF5F, "4=02", false }, + { "poc", 0xFF5F, "5=01", false }, + { "poc", 0xFF5F, "6=0000", false }, + { "poc", 0xFF5F, "10=05", false }, + + // SOT: Lsot, Isot, Psot (too small, beyond the data), TPsot. + { "base", 0xFF90, "2=000B", false }, + { "base", 0xFF90, "4=0009", false }, + { "base", 0xFF90, "6=0000000D", false }, + { "base", 0xFF90, "6=7FFFFFFF", false }, + { "base", 0xFF90, "10=01", false }, + }; + + private static byte[] BuildForKind(string kind) => kind switch + { + "base" => Stream(), + "precinct" => Stream(configure: o => o.Precincts = [(5, 5), (5, 5), (5, 5)]), + "coc" => Stream(3, configure: o => o.ComponentLevels = [2, 1, 2]), + "roi" => Stream(configure: o => o.RoiShift = 3), + "poc" => Stream(configure: o => o.Poc = [new J2kPoc(0, 0, 1, 2, 1, 4), new J2kPoc(0, 0, 1, 3, 1, 1)]), + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + + private static byte[] ApplyPatches(byte[] data, int marker, string patches) + { + var at = FindMarker(data, marker); + var result = data; + foreach (var patch in patches.Split(';')) + { + var parts = patch.Split('='); + result = Replace(result, at + int.Parse(parts[0], System.Globalization.CultureInfo.InvariantCulture), Convert.FromHexString(parts[1])); + } + + return result; + } + + /// Tests that every invalid header field value is rejected with the documented exception type and no wrapped inner exception. + [Theory] + [MemberData(nameof(InvalidFields))] + public void Jpeg2000Codec_Decode_InvalidFieldValue_IsRejected(string kind, int marker, string patches, bool unsupported) + { + var data = ApplyPatches(BuildForKind(kind), marker, patches); + if (unsupported) + { + AssertRejected(data); + } + else + { + AssertRejected(data); + } + + if (marker == 0xFF51) + { + // GetInfo parses only SIZ, so it must reject exactly the same SIZ values. + var ex = Assert.ThrowsAny(() => Jpeg2000Codec.GetInfo(new MemoryStream(data))); + Assert.IsType(unsupported ? typeof(UnsupportedImageFeatureException) : typeof(InvalidDataException), ex); + } + } + + /// Tests that the largest legal code-block (2^6 x 2^6, xcb + ycb = 12) is accepted. + [Fact] + public void Jpeg2000Codec_Decode_MaximumCodeBlockArea_IsAccepted() + { + var data = Stream(configure: o => + { + o.CodeBlockWidthExp = 6; + o.CodeBlockHeightExp = 6; + }); + Assert.Equal(8, Jpeg2000Codec.Decode(data).Width); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/DemaConsulting.CanvasNet.Tests.csproj b/test/DemaConsulting.CanvasNet.Tests/DemaConsulting.CanvasNet.Tests.csproj index 7d03fc80..b6c98423 100644 --- a/test/DemaConsulting.CanvasNet.Tests/DemaConsulting.CanvasNet.Tests.csproj +++ b/test/DemaConsulting.CanvasNet.Tests/DemaConsulting.CanvasNet.Tests.csproj @@ -101,4 +101,12 @@ + + + + PreserveNewest + + + diff --git a/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/README.md b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/README.md new file mode 100644 index 00000000..3ccd62c5 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/README.md @@ -0,0 +1,69 @@ +# JPEG 2000 Test Fixtures + +These fixtures give `Jpeg2000Codec` independent evidence from a real-world encoder. They were +generated by this project with [ImageMagick](https://imagemagick.org/) 7.1.2 (Q16), whose +JPEG 2000 delegate is the OpenJPEG library, from synthetic images that this +project created. No third-party image content is used or included, so there are no external +licensing constraints beyond the repository's own license. + +## Sources + +| File | Description | +| ------ | ------------- | +| `source_rgb.png` | 100x70, 8-bit RGB: gradients, a white disc and seeded Gaussian noise | +| `source_gray.png` | 100x70, 8-bit gray: `source_rgb.png` converted to gray | +| `source_rgba.png` | 100x70, 8-bit RGBA: `source_rgb.png` with a horizontal opacity gradient | + +Generated with (the noise is deterministic through the fixed `-seed`): + +```pwsh +magick -size 100x70 -depth 8 gradient:red-blue ` + "(" -size 100x70 gradient:black-white -rotate 90 ")" -compose Screen -composite ` + -fill white -draw "circle 50,35 50,15" -seed 12345 -attenuate 0.08 +noise Gaussian ` + -alpha off -depth 8 -strip -define png:exclude-chunks=all PNG24:source_rgb.png +magick source_rgb.png -colorspace Gray -strip -define png:exclude-chunks=all PNG8:source_gray.png +magick source_rgb.png "(" -size 100x70 gradient:white-black -rotate 90 -alpha off ")" ` + -alpha off -compose CopyOpacity -composite -define png:exclude-chunks=all PNG32:source_rgba.png +``` + +## JPEG 2000 Fixtures + +All fixtures are single-tile, 5/3 reversible wavelet streams (the only wavelet ImageMagick 7.1.2 +selects). Each was checked directly against its marker bytes (COD progression order, layer count, +decomposition levels, MCT flag, SIZ component count and bit depth) and decoded back by ImageMagick +(`magick fixture -depth 8 out.png`, `magick compare -metric AE`) before committing. + +| File | Source | Options | Verified by OpenJPEG | +| ------ | -------- | --------- | ---------------------- | +| `lossless_rct.jp2` | RGB | `jp2:rate=1`; LRCP, 5 levels, RCT | AE 0 | +| `progression_rlcp.jp2` | RGB | `jp2:rate=1`, `jp2:progression-order=RLCP` | AE 0 | +| `progression_rpcl.jp2` | RGB | `jp2:rate=1`, `jp2:progression-order=RPCL` | AE 0 | +| `progression_pcrl.jp2` | RGB | `jp2:rate=1`, `jp2:progression-order=PCRL` | AE 0 | +| `progression_cprl.jp2` | RGB | `jp2:rate=1`, `jp2:progression-order=CPRL` | AE 0 | +| `resolutions3.jp2` | RGB | `jp2:rate=1`, `jp2:number-resolutions=3` (2 levels) | AE 0 | +| `layers3.jp2` | RGB | `jp2:rate=8,4,1` (3 quality layers, last lossless) | AE 0 | +| `gray.jp2` | gray | `jp2:rate=1` (1 component) | AE 0 | +| `alpha.jp2` | RGBA | `jp2:rate=1` (4 components, opacity channel) | AE 0 | +| `depth16.jp2` | RGB | `-depth 16 -define jp2:rate=1` (16-bit samples) | AE 0 after `-depth 8` | +| `lossless_raw.j2k` | RGB | `jp2:rate=1`, raw codestream without JP2 boxes | AE 0 | +| `lossy_q30.jp2` | RGB | `jp2:quality=30` (truncated, lossy) | PSNR 31.7 dB | + +Generated with, for example: + +```pwsh +magick source_rgb.png -define jp2:rate=1 -define jp2:progression-order=RPCL progression_rpcl.jp2 +magick source_rgb.png -define jp2:rate=8,4,1 layers3.jp2 +magick source_rgb.png -depth 16 -define jp2:rate=1 depth16.jp2 +magick source_rgb.png -define jp2:quality=30 lossy_q30.jp2 +``` + +## Variants Dropped + +ImageMagick 7.1.2 exposes no tile, precinct or 9/7 irreversible options (only +`jp2:number-resolutions`, `jp2:progression-order`, `jp2:quality`, `jp2:quality-layers`, +`jp2:rate` and `jp2:reduce-factor`), and a probe confirmed the markers of such attempts were +unchanged. Tiles, precincts and the 9/7 wavelet are therefore covered only by the test-only +encoder in `Jpeg2000CodecTests`. + +The lossless fixtures are compared against the source PNG (decoded with `PngCodec`) for exact +equality; `lossy_q30.jp2` is compared with a PSNR threshold. diff --git a/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/alpha.jp2 b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/alpha.jp2 new file mode 100644 index 00000000..53c85c25 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/alpha.jp2 differ diff --git a/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/depth16.jp2 b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/depth16.jp2 new file mode 100644 index 00000000..471e0e33 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/depth16.jp2 differ diff --git a/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/gray.jp2 b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/gray.jp2 new file mode 100644 index 00000000..70f9d6b1 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/gray.jp2 differ diff --git a/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/layers3.jp2 b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/layers3.jp2 new file mode 100644 index 00000000..634ab29f Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/layers3.jp2 differ diff --git a/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/lossless_raw.j2k b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/lossless_raw.j2k new file mode 100644 index 00000000..91eb150a Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/lossless_raw.j2k differ diff --git a/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/lossless_rct.jp2 b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/lossless_rct.jp2 new file mode 100644 index 00000000..c9541270 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/lossless_rct.jp2 differ diff --git a/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/lossy_q30.jp2 b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/lossy_q30.jp2 new file mode 100644 index 00000000..6def2db2 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/lossy_q30.jp2 differ diff --git a/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/progression_cprl.jp2 b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/progression_cprl.jp2 new file mode 100644 index 00000000..a555d8bd Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/progression_cprl.jp2 differ diff --git a/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/progression_pcrl.jp2 b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/progression_pcrl.jp2 new file mode 100644 index 00000000..a739e3c2 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/progression_pcrl.jp2 differ diff --git a/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/progression_rlcp.jp2 b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/progression_rlcp.jp2 new file mode 100644 index 00000000..63ad7c92 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/progression_rlcp.jp2 differ diff --git a/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/progression_rpcl.jp2 b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/progression_rpcl.jp2 new file mode 100644 index 00000000..5b0caf68 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/progression_rpcl.jp2 differ diff --git a/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/resolutions3.jp2 b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/resolutions3.jp2 new file mode 100644 index 00000000..52d0eb19 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/resolutions3.jp2 differ diff --git a/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/source_gray.png b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/source_gray.png new file mode 100644 index 00000000..eae757d4 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/source_gray.png differ diff --git a/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/source_rgb.png b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/source_rgb.png new file mode 100644 index 00000000..f8352ff0 Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/source_rgb.png differ diff --git a/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/source_rgba.png b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/source_rgba.png new file mode 100644 index 00000000..a5a20e2e Binary files /dev/null and b/test/DemaConsulting.CanvasNet.Tests/Jpeg2000Fixtures/source_rgba.png differ