diff --git a/CHANGELOG.md b/CHANGELOG.md index 666489e..4ffea78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,11 @@ against an empty feed. decoder is checked against Group 3 and Group 4 strips Pillow wrote through libtiff, with the bitmap Pillow decodes as the reference; [`ADR-005`](docs/adr/0005-image-filter-decoders.md) schedules `DCTDecode`, `JPXDecode` and `JBIG2Decode` next. +- **Baseline JPEG images are decoded.** `DCTDecode` now yields samples — `Gray8` for one component, + `Rgb24` for three (YCbCr converted) and `Cmyk32` for four with the Adobe YCCK transform — while `Data` + keeps the stored JPEG and `DecodedPixels` carries the samples, so a caller can have either. Baseline and + extended sequential (SOF0/SOF1) are decoded; progressive (SOF2) and the fancy chroma upsampling a + subsampled fixture needs are the next step, per ADR-005. ## [1.8.0] - 2026-11-14 diff --git a/docs/SharpCorePDF-Manual-1.8.0.pdf b/docs/SharpCorePDF-Manual-1.8.0.pdf index a432a78..1458494 100644 Binary files a/docs/SharpCorePDF-Manual-1.8.0.pdf and b/docs/SharpCorePDF-Manual-1.8.0.pdf differ diff --git a/docs/adr/0005-image-filter-decoders.md b/docs/adr/0005-image-filter-decoders.md index 07112e6..ecd6323 100644 --- a/docs/adr/0005-image-filter-decoders.md +++ b/docs/adr/0005-image-filter-decoders.md @@ -1,7 +1,8 @@ # ADR-005: Decoding the image filters the reader passes through -- **Status**: **Accepted** (2026-11-14) — `RunLengthDecode` (2026-11-14) and `CCITTFaxDecode` (2026-11-15) - implemented; `DCTDecode`, `JPXDecode` and `JBIG2Decode` accepted in that order, one release slice each +- **Status**: **Accepted** (2026-11-14) — `RunLengthDecode` (2026-11-14), `CCITTFaxDecode` (2026-11-15) and + baseline `DCTDecode` (2026-11-15) implemented; progressive JPEG, `JPXDecode` and `JBIG2Decode` next, one + release slice each - **Date**: 2026-11-14 - **Related**: [`docs/parity.md`](../parity.md) row 14, [`docs/manual/src/08-inspection.md`](../../manual/src/08-inspection.md), @@ -129,12 +130,19 @@ wired; the budget rules then apply to the pixel buffer as they do to every other Order: SOF0 (baseline) first, against these fixtures; SOF1/SOF2 (extended and progressive) after it, with their own fixtures and the same ±2 tolerance. +The baseline decoder is implemented: `PdfJpegFilterTests` decodes both fixtures within two levels of +Pillow, and the contract above holds — `Data` is the stored JPEG, `DecodedPixels` the samples. The variants +the mutation survivors asked for are there too: `jpeg-420.jpg` (4:2:0, nearest upsampling and a documented +edge tolerance), `jpeg-cmyk.jpg` (four components, inverted as the `/Decode` array states) and +`jpeg-restart.jpg` (restart markers). The fancy upsampling that matches Pillow at every edge, and SOF2, +are the next tests. + ## Status of the slices | Slice | State | | --- | --- | | `RunLengthDecode` | **Implemented** (2026-11-14) | | `CCITTFaxDecode` | **Implemented** (2026-11-15) | -| `DCTDecode` | Fixtures ready (2026-11-15); baseline decoder next | +| `DCTDecode` | **Baseline (SOF0/SOF1) implemented** (2026-11-15); progressive (SOF2) next | | `JPXDecode` | Planned | | `JBIG2Decode` | Planned | diff --git a/docs/manual/src/08-inspection.md b/docs/manual/src/08-inspection.md index f2af588..4e83f0c 100644 --- a/docs/manual/src/08-inspection.md +++ b/docs/manual/src/08-inspection.md @@ -233,8 +233,8 @@ component counts, or an `Indexed` space over any of them. An image that carries case rather than an exception — its payload is its samples — so an inline image, which producers rarely compress, states its pixels the way a Flate one does. Nothing is left behind a filter either, because the filters an image realistically uses are decoded here — Flate and LZW, each with its predictor, the two -ASCII encodings, run-length, and the CCITT fax codings (Group 3 and Group 4) — so an image that a viewer -can show is an image whose samples this record states. +ASCII encodings, run-length, the CCITT fax codings (Group 3 and Group 4), and baseline JPEG — so an image +that a viewer can show is an image whose samples this record states. Two things are unpacked on the way out rather than handed over as they lie in the file. Samples narrower than a byte are widened to the same scale, so a bilevel image yields `0` and `255` rather than `0` and `1`, a @@ -250,7 +250,7 @@ which one it is: | Case | What the entry says | Why no pixels | | --- | --- | --- | -| A `DCTDecode` image | `IsDecodable` true, `Filter` `DCTDecode`, `PixelFormat` null | The bytes are already a JPEG, which a caller can read; unpacking it here would mean shipping a second decoder. | +| A progressive `DCTDecode` image (SOF2), or a baseline one whose buffer is above the budget | `IsDecodable` true, `Filter` `DCTDecode`, `PixelFormat` null | A baseline JPEG is decoded to `Gray8`, `Rgb24` or `Cmyk32`; progressive is the next variant [`ADR-005`](../../adr/0005-image-filter-decoders.md) schedules, and a buffer above the budget is measured rather than held. | | A filter the library passes through — `JPXDecode`, `JBIG2Decode`, or a name it does not know | `IsDecodable` false, `Filter` names it, and the read warns with `ImageFilterUnsupported` | Nothing was decoded, so there are no samples to hand back. [`ADR-005`](../../adr/0005-image-filter-decoders.md) schedules these decoders, in that order. | | A `Separation`, `DeviceN` or `Lab` space, sixteen bits per sample, a palette whose samples run past its table or whose table holds nothing, a width or height of zero, or a geometry the document does not state | `BitsPerComponent` or `ColorSpace` states which | The first needs a function evaluated, the second a rounding rule, the third a decision the file does not support, and the last a rectangle that is not there. Pixels are eight bit and channel-ordered by contract, so converting or guessing would hide the mismatch from the caller. | diff --git a/docs/mutation-testing.md b/docs/mutation-testing.md index c5e32fb..fcb5107 100644 --- a/docs/mutation-testing.md +++ b/docs/mutation-testing.md @@ -874,3 +874,31 @@ is short; a wide fixture is the test that would close them. Three tests were added after the run — a stream without a row count, a bitmap above the budget, and a stream that stops mid-line — and their effect is not measured here. +## DCTDecode — the baseline JPEG decoder (2026-11-15) + +The baseline decoder is a marker parser, a canonical Huffman decoder, an 8 x 8 inverse DCT and the colour +paths. Its reading: + +| File | Score | Killed | Survived | No coverage | Mutants | +| --- | --- | --- | --- | --- | --- | +| `Pdf/JpegDecoder.cs` | **61.65%** | 351 | 114 | 105 | 671 | + +The survivors are the **variants the two fixtures do not carry**, not statements to remove: + +- **Marker and segment edge cases** (`L75`–`L121`): the fixtures have one valid layout, so a wrong length, + a standalone marker, or a segment that runs past the buffer is not reached. +- **The parsers' alternate shapes** (`L207`–`L379`): a 16-bit quantisation table, a second Huffman table, a + frame with another component count, an Adobe APP14 segment, and a scan that names a table the frame does + not. +- **The plane arithmetic and upsampling** (`L418`–`L452`, `L615`–`L616`): the 16 x 16 fixture is one MCU + with H = V = 1, so `x * H / maxH` is `x` and several offsets are zero; a 4:2:0 fixture is the test that + distinguishes them. +- **Restart markers** (`L466`): the fixtures carry none. +- **The colour paths for four components** (`L644`–`L659`): a CMYK/YCCK fixture is missing. +- **The error branches** (`return false`): the no-coverage column, which a malformed-stream test covers only + in part. + +Those tests are now in — `jpeg-420.jpg`, `jpeg-cmyk.jpg`, `jpeg-restart.jpg` and a truncated stream — and +the score above predates them. The fancy upsampling that matches Pillow at every edge, and the progressive +variant, are the ADR's next slice. + diff --git a/docs/parity.md b/docs/parity.md index 8ab7325..f82cfc2 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -37,7 +37,7 @@ No “Must” row is unowned. The owner is the story that closes it: `US1 creato | 11 | Digital signatures: sign, validate, timestamp, report integrity and trust separately | PDFsharp: yes. PdfPig: no. | Must: detached CMS over a reserved byte range, X.509, optional caller-supplied RFC 3161 token, integrity and trust as separate results. | 1.1 | US4 trust | `PdfDocumentSignerTests.Writes_a_signature_that_verifies_against_its_own_bytes`, `Signing_a_second_time_keeps_the_first_signature_valid`, `Embeds_the_timestamp_token_the_caller_supplies` (T080, T087), `PdfDocumentSignatureValidatorTests.Reports_a_changed_byte_as_a_digest_mismatch`, `Keeps_integrity_independent_of_trust`, `Reports_a_request_for_an_online_check_without_making_one` (T081) | `python docs/tools/validate-signatures.py --trust-signer ` (pyHanko, T093) and `uv run --with pikepdf python docs/tools/validate-pdf.py ` | Met (2026-10-23) on pyHanko and qpdf: a signed revision reports `intact` with coverage `ENTIRE_FILE`, a twice-signed one reports `intact`/`ENTIRE_REVISION` then `intact`/`ENTIRE_FILE`, both `trusted` against the signer's own certificate as anchor, and qpdf `--check` reports no problems on either file. A timestamp token is embedded, never checked: the authority is the caller's. The `sign-and-archive` example (T094) writes a PDF/A-3b file and signs it afterwards — the order that keeps the archival bytes intact — and pyHanko reads what it wrote as field `ArchivalSignature`, `integrity intact (coverage ENTIRE_FILE)`, signed at the moment the options state (2026-10-24) | | 12 | PDF/A-1b, -2b, -2u, -3b, -3u and -4 output | PDFsharp: PDF/A manager. PdfPig: no. | Must: embed every font, write XMP and an output intent, remove encryption and forbidden actions, refuse what cannot conform. | 1.1 | US4 trust | `PdfATests.Writes_the_structures_the_profile_requires`, `Refuses_a_font_it_cannot_embed`, `Refuses_transparency_for_pdf_a_1b_but_writes_it_for_2b` (T082) | `python docs/tools/validate-pdfa.py --verapdf --profile 2b archive-2b.pdf`, and the same for every other profile: veraPDF 1.30.4 reports `PASS` for all six profiles of an authored page and of `SafeWebCore.pdf`, by hand and on every push (`compliance.yml`; T093, T096) | Met — the output intent is sRGB built in, or the caller's own ICC profile and condition, written as supplied and checked as ICC first; PDF/A-3 embedded files are associated through `/AF` with a stated relationship; levels B and U are written, and PDF/A-4 writes the base level of ISO 19005-4; PDF/A-4f/-4u, a merge-time profile and level A are out of scope, and no conformance is claimed without a validator | | 13 | Positioned letters, words and blocks; configurable segmentation and reading order | PDFsharp: not a reading API. PdfPig: yes. | Must: `PdfLetter` with Unicode, size, font name, box and baseline; word and block strategies; raw and column-aware reading order with the strategy in the result. | 1.1 | US3 reading | `PdfLayoutReaderTests.Reads_one_letter_per_glyph_in_content_order`, `Splits_a_two_column_page_into_one_block_per_column`, `Reads_a_page_of_two_columns_left_to_right_whatever_order_the_content_used` (T059–T060), `PdfLayoutCorpusTests.Meets_SC_004_on_the_labelled_corpus`, `PdfLayoutCorpusTests.Table_page_is_still_read_column_by_column` (T076) | `python docs/tools/score-layout.py --labels tests/SharpCorePDF.Tests/Assets/parity/labelled --known-order-mismatch table` (T075) | Met on the labelled corpus (T075–T076): recall and boxes 100% on all five documents, reading order 100% on four. `table` is recorded as a known order mismatch — a table is symmetric under the XY cut, so a cell becomes a block and the order comes out column by column, which is the order a two-column page needs; table semantics are out of contract. `PdfLayoutCorpusTests.Table_page_is_still_read_column_by_column` fails if the limitation ever goes away | -| 14 | Images, hyperlinks, form values, annotations, embedded files, metadata, bookmarks | PDFsharp: partial. PdfPig: yes. | Must: expose each resource through the read side, decode image pixels when asked, and warn on an unresolved destination or unsupported filter. | 1.1 partial, pixels in 1.2 | US3 reading | `PdfLayoutReaderTests.Reads_the_links_of_a_page_and_their_uris`, `Resolves_a_direct_and_a_named_destination`, `Keeps_an_unresolved_destination_and_says_so`, `PdfContentReaderTests.Returns_every_annotation_with_its_geometry_and_text`, `Returns_the_field_tree_with_qualified_names`, `PdfContentReaderTests.Exposes_the_samples_of_a_decoded_image_as_pixels`, `Does_not_expose_a_jpeg_as_pixels`, `Does_not_expose_the_pixels_of_a_filter_it_cannot_decode`, `Exposes_a_palette_image_as_the_colours_of_its_table`, `Exposes_a_palette_whose_table_is_a_string`, `Exposes_bilevel_samples_as_one_byte_per_sample`, `Exposes_four_bit_samples_at_the_scale_of_their_depth`, `Exposes_two_bit_samples_at_the_scale_of_their_depth`, `Exposes_a_cmyk_image_as_four_device_samples_per_pixel`, `Exposes_the_samples_of_an_iccbased_image`, `Does_not_expose_the_samples_of_a_separation_space`, `Does_not_expose_a_palette_image_whose_index_is_outside_its_table`, `Leaves_an_expansion_above_the_budget_out_of_the_record`, `Exposes_the_samples_of_an_image_that_declares_no_filter`, `Keeps_an_expansion_that_fits_the_budget_exactly`, `Exposes_four_bit_samples_of_a_three_channel_image`, `Does_not_expose_an_image_that_states_no_extent`, `Exposes_the_samples_of_a_profile_by_the_components_it_states`, `Does_not_read_a_palette_whose_table_is_empty`, `Refuses_to_resolve_a_colour_space_without_a_document`, `Resolves_the_calibrated_forms_of_the_device_spaces`, `Does_not_build_a_palette_over_a_palette_or_over_a_function`, `Reads_a_palette_whose_declared_index_is_zero`, `Clamps_a_declared_index_to_the_entries_the_table_holds`, `Does_not_read_an_icc_space_that_states_no_profile`, `Exposes_the_pixels_of_an_inline_image_stated_by_an_abbreviation` (T072) | `uv run --with pypdf python -c "from pypdf import PdfReader; ..."` and `validate-pdf.py` on the corpus, plus the `score-layout.py` run of T079 (5 documents, 0 short of a threshold) and an external parse of all four export formats (T079) | Met in 1.2 (2026-10-24): every resource the row names is exposed — images with their payload, geometry, mask and pixels, hyperlinks with resolved and unresolved destinations, form values through `PdfFormFieldInfo`, annotations, embedded files, metadata and bookmarks; pixels are `Gray8`, `Rgb24` or `Cmyk32` for every filter the library decodes and for an image whose stream declares no filter at all — a palette applied so an `Indexed` image yields colours, samples narrower than a byte widened, four device samples left as CMYK rather than converted — and `null` for a filter it does not decode, which is reported with `ImageFilterUnsupported` rather than dropped. `RunLengthDecode` (2026-11-14) and `CCITTFaxDecode` (2026-11-15) joined the decoded filters and yield pixels; `DCTDecode`, `JPXDecode` and `JBIG2Decode` remain pass-through, in the order ADR-005 schedules them | +| 14 | Images, hyperlinks, form values, annotations, embedded files, metadata, bookmarks | PDFsharp: partial. PdfPig: yes. | Must: expose each resource through the read side, decode image pixels when asked, and warn on an unresolved destination or unsupported filter. | 1.1 partial, pixels in 1.2 | US3 reading | `PdfLayoutReaderTests.Reads_the_links_of_a_page_and_their_uris`, `Resolves_a_direct_and_a_named_destination`, `Keeps_an_unresolved_destination_and_says_so`, `PdfContentReaderTests.Returns_every_annotation_with_its_geometry_and_text`, `Returns_the_field_tree_with_qualified_names`, `PdfContentReaderTests.Exposes_the_samples_of_a_decoded_image_as_pixels`, `Does_not_expose_a_jpeg_as_pixels`, `Does_not_expose_the_pixels_of_a_filter_it_cannot_decode`, `Exposes_a_palette_image_as_the_colours_of_its_table`, `Exposes_a_palette_whose_table_is_a_string`, `Exposes_bilevel_samples_as_one_byte_per_sample`, `Exposes_four_bit_samples_at_the_scale_of_their_depth`, `Exposes_two_bit_samples_at_the_scale_of_their_depth`, `Exposes_a_cmyk_image_as_four_device_samples_per_pixel`, `Exposes_the_samples_of_an_iccbased_image`, `Does_not_expose_the_samples_of_a_separation_space`, `Does_not_expose_a_palette_image_whose_index_is_outside_its_table`, `Leaves_an_expansion_above_the_budget_out_of_the_record`, `Exposes_the_samples_of_an_image_that_declares_no_filter`, `Keeps_an_expansion_that_fits_the_budget_exactly`, `Exposes_four_bit_samples_of_a_three_channel_image`, `Does_not_expose_an_image_that_states_no_extent`, `Exposes_the_samples_of_a_profile_by_the_components_it_states`, `Does_not_read_a_palette_whose_table_is_empty`, `Refuses_to_resolve_a_colour_space_without_a_document`, `Resolves_the_calibrated_forms_of_the_device_spaces`, `Does_not_build_a_palette_over_a_palette_or_over_a_function`, `Reads_a_palette_whose_declared_index_is_zero`, `Clamps_a_declared_index_to_the_entries_the_table_holds`, `Does_not_read_an_icc_space_that_states_no_profile`, `Exposes_the_pixels_of_an_inline_image_stated_by_an_abbreviation` (T072) | `uv run --with pypdf python -c "from pypdf import PdfReader; ..."` and `validate-pdf.py` on the corpus, plus the `score-layout.py` run of T079 (5 documents, 0 short of a threshold) and an external parse of all four export formats (T079) | Met in 1.2 (2026-10-24): every resource the row names is exposed — images with their payload, geometry, mask and pixels, hyperlinks with resolved and unresolved destinations, form values through `PdfFormFieldInfo`, annotations, embedded files, metadata and bookmarks; pixels are `Gray8`, `Rgb24` or `Cmyk32` for every filter the library decodes and for an image whose stream declares no filter at all — a palette applied so an `Indexed` image yields colours, samples narrower than a byte widened, four device samples left as CMYK rather than converted — and `null` for a filter it does not decode, which is reported with `ImageFilterUnsupported` rather than dropped. `RunLengthDecode` (2026-11-14), `CCITTFaxDecode` (2026-11-15) and baseline `DCTDecode` (2026-11-15) joined the decoded filters and yield pixels; progressive JPEG, `JPXDecode` and `JBIG2Decode` remain pass-through, in the order ADR-005 schedules them | | 15 | Public read-only object graph and content-stream inspection | PDFsharp: partial. PdfPig: yes. | Must: immutable `PdfObjectView`/`PdfContentOperationView`, dangling references visible, operation offsets reported. | 1.1 tests, the rest 1.2 (completed 2026-11-06, Unreleased) | US3 reading | `PdfObjectInspectorTests.Returns_one_node_per_listed_object_in_number_order`, `Projects_a_reference_as_a_node_and_never_follows_it`, `Reports_a_dangling_reference_once_for_the_target_it_names`, `Reads_the_operations_of_a_page_in_the_order_it_states_them` | `python docs/tools/validate-pdf.py artifacts/graph.pdf` on the object-graph fixture, run 2026-11-06: qpdf names the file valid with warnings, and the warnings are exactly the two deliberate dangling references to object 99 the fixture states; an independent scan of the same bytes finds objects 1-7 and no object 99, which is what the inspector reports (seven nodes, three unresolved references). The mutation attempt is recorded too: 77.8% over `PdfObjectInspector.cs` | Evidenced (2026-11-06) - twelve tests in `PdfObjectInspectorTests` are in the suite, the external run above agrees with the inspector on which objects exist and which reference dangles, and the mutation score for the inspector is recorded rather than assumed | | 16 | Export to ALTO, PAGE XML, hOCR and SVG text | PDFsharp: no. PdfPig: yes. | Must: four exporters in page space, escaped text, a valid empty-page element, and a result that names the strategy used. | 1.1 exporters, the rest 1.2 (completed 2026-11-06, Unreleased) | US3 reading | `PdfLayoutExportTests.Exports_alto_pagexml_hocr_and_svg`, `Writes_a_page_element_for_a_page_with_no_text`, `Escapes_text_that_looks_like_markup`, `Returns_the_blocks_it_wrote_with_the_strategy_that_produced_them` (T063) `Writes_the_metadata_times_the_page_schema_requires` (2026-11-06) | ALTO 4.2 and PAGE XML 2019-07-15 validated with `lxml` against the published schemas (`alto-4-2.xsd`, `pagecontent-2019-07-15.xsd`), run 2026-11-06: both exports valid after the run found the PAGE document stating a creator and no times - the schema requires `Created` and `LastChange`, which the exporter now writes at a fixed instant so the bytes stay reproducible | Evidenced (2026-11-06) - four exporters and their tests are in the suite, the hOCR and SVG documents are checked by their own tests, and the schema run T079 named is recorded: the ALTO and PAGE exports validate against the published schemas, with the PAGE metadata defect the run exposed now fixed and pinned by a test | | 17 | Rendering pages to images or raster previews | PDFsharp: no. PdfPig: no. | Non-goal: no rasteriser, thumbnail generator, viewer or print pipeline. | 1.1 (excluded) | Product boundary (ADR-001) | None by design | Out-of-scope statement in `README.md`, `docs/manual/src/02-what-it-is-not.md` and ADR-001 | Out of scope | diff --git a/src/SharpCorePDF.Infrastructure/Pdf/JpegDecoder.cs b/src/SharpCorePDF.Infrastructure/Pdf/JpegDecoder.cs new file mode 100644 index 0000000..64dce0b --- /dev/null +++ b/src/SharpCorePDF.Infrastructure/Pdf/JpegDecoder.cs @@ -0,0 +1,820 @@ +namespace SharpCorePDF.Infrastructure.Pdf; + +/// +/// Decodes a baseline (SOF0) or extended sequential (SOF1) JPEG to its samples. +/// +/// +/// +/// The decoder is a baseline one: one scan, canonical Huffman coding, an 8 x 8 inverse DCT, and the three +/// component layouts a PDF image states — Gray, RGB/YCbCr and CMYK/YCCK. Progressive (SOF2) is refused +/// for now; ADR-005 records it as the next variant. +/// +/// +/// The samples are the values the JPEG stores, interleaved, one byte per component: Gray8 for one +/// component, Rgb24 for three (YCbCr is converted) and Cmyk32 for four. The caller pairs the +/// length with the image's own geometry. +/// +/// +internal sealed class JpegDecoder +{ + private static readonly int[] ZigZag = + [ + 0, 1, 8, 16, 9, 2, 3, 10, + 17, 24, 32, 25, 18, 11, 4, 5, + 12, 19, 26, 33, 40, 48, 41, 34, + 27, 20, 13, 6, 7, 14, 21, 28, + 35, 42, 49, 56, 57, 50, 43, 36, + 29, 22, 15, 23, 30, 37, 44, 51, + 58, 59, 52, 45, 38, 31, 39, 46, + 53, 60, 61, 54, 47, 55, 62, 63, + ]; + + private static readonly double[] Cosines = CreateCosines(); + + private readonly ReadOnlyMemory data; + private readonly int[][] quantisation = new int[4][]; + private readonly JpegHuffman?[] dcTables = new JpegHuffman?[4]; + private readonly JpegHuffman?[] acTables = new JpegHuffman?[4]; + + private JpegComponent[] components = []; + private int width; + private int height; + private int maxH; + private int maxV; + private int restartInterval; + private int adobeTransform = -1; + + private JpegDecoder(ReadOnlyMemory data) => this.data = data; + + /// Decodes a JPEG to its samples. + /// The JPEG bytes. + /// The largest pixel buffer that is kept. + /// The interleaved samples, or null when the buffer is above the cap. + /// The pixel format the samples state, or null when nothing decoded. + /// The width of the JPEG in pixels. + /// The height of the JPEG in pixels. + /// True when a baseline JPEG decoded. + public static bool TryDecode( + ReadOnlyMemory data, + int maxBytes, + out byte[]? pixels, + out string? format, + out int width, + out int height) => + new JpegDecoder(data).Run(maxBytes, out pixels, out format, out width, out height); + + private bool Run(int maxBytes, out byte[]? pixels, out string? format, out int width, out int height) + { + pixels = null; + format = null; + width = 0; + height = 0; + + var span = data.Span; + + if (span.Length < 4 || span[0] != 0xFF || span[1] != 0xD8) + { + return false; + } + + var offset = 2; + + while (offset + 1 < span.Length) + { + if (span[offset] != 0xFF) + { + offset++; + continue; + } + + while (offset < span.Length && span[offset] == 0xFF) + { + offset++; + } + + if (offset >= span.Length) + { + break; + } + + var marker = span[offset++]; + + if (marker == 0xD9) + { + break; + } + + if (marker is 0x01 or (>= 0xD0 and <= 0xD7)) + { + continue; + } + + if (offset + 2 > span.Length) + { + return false; + } + + var length = (span[offset] << 8) | span[offset + 1]; + + if (length < 2 || offset + length > span.Length) + { + return false; + } + + var segment = span.Slice(offset + 2, length - 2); + + switch (marker) + { + case 0xDB: + if (!ParseQuantisation(segment)) + { + return false; + } + + break; + case 0xC4: + if (!ParseHuffman(segment)) + { + return false; + } + + break; + case 0xC0: + case 0xC1: + if (!ParseFrame(segment)) + { + return false; + } + + break; + case 0xC2: + // Progressive: accepted by ADR-005 after the baseline, not in this slice. + return false; + case 0xDD: + if (segment.Length < 2) + { + return false; + } + + restartInterval = (segment[0] << 8) | segment[1]; + break; + case 0xEE: + ParseAdobe(segment); + break; + case 0xDA: + if (!ParseScan(segment)) + { + return false; + } + + if (!DecodeScan(offset + length, maxBytes, out pixels, out format, out width, out height)) + { + return false; + } + + return format is not null; + } + + offset += length; + } + + return false; + } + + private static double[] CreateCosines() + { + var table = new double[64]; + + for (var x = 0; x < 8; x++) + { + for (var u = 0; u < 8; u++) + { + table[(x * 8) + u] = Math.Cos(((2 * x) + 1) * u * Math.PI / 16.0) + * (u == 0 ? 1.0 / Math.Sqrt(2.0) : 1.0); + } + } + + return table; + } + + private bool ParseQuantisation(ReadOnlySpan segment) + { + var offset = 0; + + while (offset < segment.Length) + { + var information = segment[offset++]; + var precision = information >> 4; + var table = information & 15; + + if (table > 3 || precision > 1) + { + return false; + } + + var size = precision == 0 ? 64 : 128; + + if (offset + size > segment.Length) + { + return false; + } + + var values = new int[64]; + + for (var index = 0; index < 64; index++) + { + values[ZigZag[index]] = precision == 0 + ? segment[offset + index] + : (segment[offset + (index * 2)] << 8) | segment[offset + (index * 2) + 1]; + } + + quantisation[table] = values; + offset += size; + } + + return true; + } + + private bool ParseHuffman(ReadOnlySpan segment) + { + var offset = 0; + + while (offset < segment.Length) + { + if (offset + 17 > segment.Length) + { + return false; + } + + var information = segment[offset++]; + var tableClass = information >> 4; + var tableId = information & 15; + + if (tableId > 3 || tableClass > 1) + { + return false; + } + + var counts = segment.Slice(offset, 16); + offset += 16; + + var total = 0; + + foreach (var count in counts) + { + total += count; + } + + if (total == 0 || offset + total > segment.Length) + { + return false; + } + + var symbols = segment.Slice(offset, total); + offset += total; + + var table = JpegHuffman.Create(counts, symbols); + + if (tableClass == 0) + { + dcTables[tableId] = table; + } + else + { + acTables[tableId] = table; + } + } + + return true; + } + + private bool ParseFrame(ReadOnlySpan segment) + { + if (segment.Length < 6 || segment[0] != 8) + { + return false; + } + + height = (segment[1] << 8) | segment[2]; + width = (segment[3] << 8) | segment[4]; + var count = segment[5]; + + if (width <= 0 || height <= 0 || count is < 1 or > 4 || segment.Length < 6 + (count * 3)) + { + return false; + } + + var parsed = new JpegComponent[count]; + var horizontal = 0; + var vertical = 0; + + for (var index = 0; index < count; index++) + { + var offset = 6 + (index * 3); + var component = new JpegComponent + { + Id = segment[offset], + H = segment[offset + 1] >> 4, + V = segment[offset + 1] & 15, + Quantisation = segment[offset + 2], + }; + + if (component.H is < 1 or > 4 || component.V is < 1 or > 4 || component.Quantisation > 3) + { + return false; + } + + parsed[index] = component; + horizontal = Math.Max(horizontal, component.H); + vertical = Math.Max(vertical, component.V); + } + + this.components = parsed; + maxH = horizontal; + maxV = vertical; + + return true; + } + + private bool ParseScan(ReadOnlySpan segment) + { + if (components.Length == 0 || segment.Length < 1) + { + return false; + } + + var count = segment[0]; + + if (count is < 1 or > 4 || segment.Length < 1 + (count * 2) + 3) + { + return false; + } + + for (var index = 0; index < count; index++) + { + var id = segment[1 + (index * 2)]; + var tables = segment[2 + (index * 2)]; + var component = Array.Find(components, candidate => candidate.Id == id); + + if (component is null) + { + return false; + } + + component.DcTable = tables >> 4; + component.AcTable = tables & 15; + } + + return true; + } + + private void ParseAdobe(ReadOnlySpan segment) + { + if (segment.Length >= 12 + && segment[0] == (byte)'A' + && segment[1] == (byte)'d' + && segment[2] == (byte)'o' + && segment[3] == (byte)'b' + && segment[4] == (byte)'e') + { + adobeTransform = segment[11]; + } + } + + private sealed class JpegComponent + { + public int Id { get; init; } + + public int H { get; init; } + + public int V { get; init; } + + public int Quantisation { get; init; } + + public int DcTable { get; set; } + + public int AcTable { get; set; } + + public byte[] Plane { get; set; } = []; + + public int Stride { get; set; } + + public int Predictor { get; set; } + } + + private bool DecodeScan( + int scanStart, + int maxBytes, + out byte[]? pixels, + out string? format, + out int width, + out int height) + { + pixels = null; + format = null; + width = this.width; + height = this.height; + + var mcuWidth = 8 * maxH; + var mcuHeight = 8 * maxV; + var mcuColumns = (this.width + mcuWidth - 1) / mcuWidth; + var mcuRows = (this.height + mcuHeight - 1) / mcuHeight; + + foreach (var component in components) + { + component.Stride = mcuColumns * 8 * component.H; + component.Plane = new byte[component.Stride * mcuRows * 8 * component.V]; + component.Predictor = 0; + } + + var bits = new JpegBits(data.Span, scanStart); + var coefficients = new int[64]; + var samples = new byte[64]; + var mcu = 0; + var total = mcuColumns * mcuRows; + + for (var mcuY = 0; mcuY < mcuRows; mcuY++) + { + for (var mcuX = 0; mcuX < mcuColumns; mcuX++) + { + foreach (var component in components) + { + for (var vertical = 0; vertical < component.V; vertical++) + { + for (var horizontal = 0; horizontal < component.H; horizontal++) + { + if (!DecodeBlock(ref bits, component, coefficients, samples)) + { + return false; + } + + var x = (mcuX * 8 * component.H) + (horizontal * 8); + var y = (mcuY * 8 * component.V) + (vertical * 8); + + for (var row = 0; row < 8; row++) + { + samples + .AsSpan(row * 8, 8) + .CopyTo(component.Plane.AsSpan(((y + row) * component.Stride) + x)); + } + } + } + } + + mcu++; + + if (restartInterval > 0 && mcu % restartInterval == 0 && mcu < total) + { + if (!bits.TryReadRestart()) + { + return false; + } + + foreach (var component in components) + { + component.Predictor = 0; + } + } + } + } + + return BuildOutput(maxBytes, out pixels, out format); + } + + private bool DecodeBlock(ref JpegBits bits, JpegComponent component, int[] coefficients, byte[] samples) + { + var dcTable = dcTables[component.DcTable]; + var acTable = acTables[component.AcTable]; + var quant = quantisation[component.Quantisation]; + + if (dcTable is null || acTable is null || quant is null) + { + return false; + } + + Array.Clear(coefficients); + + if (!dcTable.TryRead(ref bits, out var size) || size > 11) + { + return false; + } + + var difference = 0; + + if (size > 0) + { + if (!bits.ReadBits(size, out var value)) + { + return false; + } + + difference = Extend(value, size); + } + + component.Predictor += difference; + coefficients[0] = component.Predictor * quant[0]; + + var index = 1; + + while (index < 64) + { + if (!acTable.TryRead(ref bits, out var symbol)) + { + return false; + } + + var run = symbol >> 4; + var coefficientWidth = symbol & 15; + + if (coefficientWidth == 0) + { + if (run == 15) + { + index += 16; + continue; + } + + break; + } + + index += run; + + if (index >= 64 || !bits.ReadBits(coefficientWidth, out var value)) + { + return false; + } + + coefficients[ZigZag[index]] = Extend(value, coefficientWidth) * quant[ZigZag[index]]; + index++; + } + + Idct(coefficients, samples); + return true; + } + + private static int Extend(int value, int size) => + value < (1 << (size - 1)) ? value - ((1 << size) - 1) : value; + + private static void Idct(int[] coefficients, byte[] samples) + { + for (var y = 0; y < 8; y++) + { + for (var x = 0; x < 8; x++) + { + var sum = 0.0; + + for (var v = 0; v < 8; v++) + { + for (var u = 0; u < 8; u++) + { + sum += Cosines[(x * 8) + u] * Cosines[(y * 8) + v] * coefficients[(v * 8) + u]; + } + } + + samples[(y * 8) + x] = (byte)Math.Clamp((int)Math.Round((sum / 4.0) + 128.0), 0, 255); + } + } + } + + private bool BuildOutput(int maxBytes, out byte[]? pixels, out string? format) + { + pixels = null; + format = null; + var count = components.Length; + format = count switch + { + 1 => "Gray8", + 3 => "Rgb24", + 4 => "Cmyk32", + _ => null, + }; + + if (format is null) + { + return false; + } + + var length = (long)width * height * count; + + if (length > maxBytes) + { + return true; + } + + var output = new byte[length]; + var target = 0; + var samples = new int[count]; + + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + for (var component = 0; component < count; component++) + { + var source = components[component]; + var sx = (x * source.H) / maxH; + var sy = (y * source.V) / maxV; + samples[component] = source.Plane[(sy * source.Stride) + sx]; + } + + if (count == 1) + { + output[target++] = (byte)samples[0]; + continue; + } + + if (count == 3) + { + if (adobeTransform == 0) + { + output[target++] = (byte)samples[0]; + output[target++] = (byte)samples[1]; + output[target++] = (byte)samples[2]; + continue; + } + + var cb = samples[1] - 128; + var cr = samples[2] - 128; + output[target++] = Clamp(samples[0] + (1.402 * cr)); + output[target++] = Clamp(samples[0] - (0.344136 * cb) - (0.714136 * cr)); + output[target++] = Clamp(samples[0] + (1.772 * cb)); + continue; + } + + if (adobeTransform == 2) + { + var cb = samples[1] - 128; + var cr = samples[2] - 128; + output[target++] = Clamp(255.0 - (samples[0] + (1.402 * cr))); + output[target++] = Clamp(255.0 - (samples[0] - (0.344136 * cb) - (0.714136 * cr))); + output[target++] = Clamp(255.0 - (samples[0] + (1.772 * cb))); + output[target++] = (byte)samples[3]; + continue; + } + + output[target++] = (byte)samples[0]; + output[target++] = (byte)samples[1]; + output[target++] = (byte)samples[2]; + output[target++] = (byte)samples[3]; + } + } + + pixels = output; + return true; + } + + private static byte Clamp(double value) => + (byte)Math.Clamp((int)Math.Round(value), 0, 255); + + private sealed class JpegHuffman + { + private readonly Dictionary table = []; + + public static JpegHuffman Create(ReadOnlySpan counts, ReadOnlySpan symbols) + { + var huffman = new JpegHuffman(); + var code = 0; + var index = 0; + + for (var length = 1; length <= 16; length++) + { + for (var count = 0; count < counts[length - 1]; count++) + { + huffman.table[(length << 16) | code] = symbols[index++]; + code++; + } + + code <<= 1; + } + + return huffman; + } + + public bool TryRead(ref JpegBits bits, out int symbol) + { + var code = 0; + + for (var length = 1; length <= 16; length++) + { + if (!bits.TryReadBit(out var bit)) + { + symbol = 0; + return false; + } + + code = (code << 1) | bit; + + if (table.TryGetValue((length << 16) | code, out symbol)) + { + return true; + } + } + + symbol = 0; + return false; + } + } + + private ref struct JpegBits + { + private readonly ReadOnlySpan data; + private int offset; + private int bit; + private int current; + + public JpegBits(ReadOnlySpan data, int offset) + { + this.data = data; + this.offset = offset; + bit = 8; + current = 0; + } + + public bool TryReadBit(out int value) + { + if (bit == 8) + { + if (!TryLoad()) + { + value = 0; + return false; + } + + bit = 0; + } + + value = (current >> (7 - bit)) & 1; + bit++; + return true; + } + + public bool ReadBits(int count, out int value) + { + value = 0; + + for (var index = 0; index < count; index++) + { + if (!TryReadBit(out var next)) + { + return false; + } + + value = (value << 1) | next; + } + + return true; + } + + public bool TryReadRestart() + { + bit = 8; + + if (offset + 1 >= data.Length || data[offset] != 0xFF) + { + return false; + } + + var marker = data[offset + 1]; + + if (marker is < 0xD0 or > 0xD7) + { + return false; + } + + offset += 2; + return true; + } + + private bool TryLoad() + { + if (offset >= data.Length) + { + return false; + } + + var value = data[offset]; + + if (value == 0xFF) + { + if (offset + 1 >= data.Length) + { + return false; + } + + if (data[offset + 1] != 0x00) + { + // A marker ends the entropy-coded data; a restart is read by TryReadRestart. + return false; + } + + offset += 2; + current = 0xFF; + return true; + } + + offset++; + current = value; + return true; + } + } +} diff --git a/src/SharpCorePDF.Infrastructure/PdfPageContentReader.Images.cs b/src/SharpCorePDF.Infrastructure/PdfPageContentReader.Images.cs index bb6f0ec..9f9c48d 100644 --- a/src/SharpCorePDF.Infrastructure/PdfPageContentReader.Images.cs +++ b/src/SharpCorePDF.Infrastructure/PdfPageContentReader.Images.cs @@ -225,6 +225,21 @@ public List Build() entry.Data, maxInlineBytes); + // A DCT image keeps its stored JPEG in Data; the samples are decoded here, so a caller + // that wants the JPEG still has it and a caller that wants pixels gets them. + if (pixels is null + && entry.IsDecodable + && entry.Data is { } jpeg + && entry.Filter?.Contains("DCTDecode", StringComparison.Ordinal) == true + && JpegDecoder.TryDecode(jpeg, maxInlineBytes, out var jpegPixels, out var jpegFormat, out var jpegWidth, out var jpegHeight) + && jpegPixels is not null + && jpegWidth == entry.Width + && jpegHeight == entry.Height) + { + format = jpegFormat; + pixels = jpegPixels; + } + images.Add(new PdfImageInfo { Id = entry.Id, diff --git a/tests/SharpCorePDF.Tests/Assets/jpeg/jpeg-420-expected.bin b/tests/SharpCorePDF.Tests/Assets/jpeg/jpeg-420-expected.bin new file mode 100644 index 0000000..cebeb3a Binary files /dev/null and b/tests/SharpCorePDF.Tests/Assets/jpeg/jpeg-420-expected.bin differ diff --git a/tests/SharpCorePDF.Tests/Assets/jpeg/jpeg-420.jpg b/tests/SharpCorePDF.Tests/Assets/jpeg/jpeg-420.jpg new file mode 100644 index 0000000..ba6b631 Binary files /dev/null and b/tests/SharpCorePDF.Tests/Assets/jpeg/jpeg-420.jpg differ diff --git a/tests/SharpCorePDF.Tests/Assets/jpeg/jpeg-cmyk-expected.bin b/tests/SharpCorePDF.Tests/Assets/jpeg/jpeg-cmyk-expected.bin new file mode 100644 index 0000000..58a3ba1 Binary files /dev/null and b/tests/SharpCorePDF.Tests/Assets/jpeg/jpeg-cmyk-expected.bin differ diff --git a/tests/SharpCorePDF.Tests/Assets/jpeg/jpeg-cmyk.jpg b/tests/SharpCorePDF.Tests/Assets/jpeg/jpeg-cmyk.jpg new file mode 100644 index 0000000..fac138b Binary files /dev/null and b/tests/SharpCorePDF.Tests/Assets/jpeg/jpeg-cmyk.jpg differ diff --git a/tests/SharpCorePDF.Tests/Assets/jpeg/jpeg-restart-expected.bin b/tests/SharpCorePDF.Tests/Assets/jpeg/jpeg-restart-expected.bin new file mode 100644 index 0000000..64dc774 Binary files /dev/null and b/tests/SharpCorePDF.Tests/Assets/jpeg/jpeg-restart-expected.bin differ diff --git a/tests/SharpCorePDF.Tests/Assets/jpeg/jpeg-restart.jpg b/tests/SharpCorePDF.Tests/Assets/jpeg/jpeg-restart.jpg new file mode 100644 index 0000000..e01eaae Binary files /dev/null and b/tests/SharpCorePDF.Tests/Assets/jpeg/jpeg-restart.jpg differ diff --git a/tests/SharpCorePDF.Tests/PdfJpegFilterTests.cs b/tests/SharpCorePDF.Tests/PdfJpegFilterTests.cs new file mode 100644 index 0000000..ceedee4 --- /dev/null +++ b/tests/SharpCorePDF.Tests/PdfJpegFilterTests.cs @@ -0,0 +1,133 @@ +using SharpCorePDF.Infrastructure.Pdf; + +namespace SharpCorePDF.Tests; + +/// +/// Tests of the baseline JPEG decoder against strips Pillow wrote and decoded itself: the samples the +/// decoder states must be the samples Pillow states, to within the two levels a different IDCT may move. +/// +public sealed class PdfJpegFilterTests +{ + private static readonly byte[] Rgb = File.ReadAllBytes(Fixture("jpeg-444.jpg")); + private static readonly byte[] RgbExpected = File.ReadAllBytes(Fixture("jpeg-444-expected.bin")); + private static readonly byte[] Gray = File.ReadAllBytes(Fixture("jpeg-gray.jpg")); + private static readonly byte[] GrayExpected = File.ReadAllBytes(Fixture("jpeg-gray-expected.bin")); + private static readonly byte[] Rgb420 = File.ReadAllBytes(Fixture("jpeg-420.jpg")); + private static readonly byte[] Rgb420Expected = File.ReadAllBytes(Fixture("jpeg-420-expected.bin")); + private static readonly byte[] Cmyk = File.ReadAllBytes(Fixture("jpeg-cmyk.jpg")); + private static readonly byte[] CmykExpected = File.ReadAllBytes(Fixture("jpeg-cmyk-expected.bin")); + private static readonly byte[] Restart = File.ReadAllBytes(Fixture("jpeg-restart.jpg")); + private static readonly byte[] RestartExpected = File.ReadAllBytes(Fixture("jpeg-restart-expected.bin")); + + private static string Fixture(string name) => Path.Combine(TestAssets.Folder, "jpeg", name); + + /// A 4:4:4 RGB JPEG decodes to the bytes Pillow decodes from the same file. + [Fact] + public void Decodes_a_baseline_rgb_jpeg_within_two_levels() + { + Assert.True(JpegDecoder.TryDecode(Rgb, 1 << 20, out var pixels, out var format, out var width, out var height)); + + Assert.Equal("Rgb24", format); + Assert.Equal(16, width); + Assert.Equal(16, height); + Assert.NotNull(pixels); + Assert.Equal(RgbExpected.Length, pixels!.Length); + Assert.InRange(MaxDifference(pixels, RgbExpected), 0, 2); + } + + /// One component decodes as Gray8, with the same tolerance. + [Fact] + public void Decodes_a_baseline_gray_jpeg_within_two_levels() + { + Assert.True(JpegDecoder.TryDecode(Gray, 1 << 20, out var pixels, out var format, out var width, out var height)); + + Assert.Equal("Gray8", format); + Assert.Equal(16, width); + Assert.Equal(16, height); + Assert.NotNull(pixels); + Assert.Equal(GrayExpected.Length, pixels!.Length); + Assert.InRange(MaxDifference(pixels, GrayExpected), 0, 2); + } + + /// + /// A 4:2:0 JPEG halves the chroma in both directions. The decoder upsamples with the nearest sample, + /// where Pillow uses the fancy filter, so the tolerance is the edge difference rather than two levels. + /// + [Fact] + public void Decodes_a_subsampled_jpeg_within_the_upsampling_tolerance() + { + Assert.True(JpegDecoder.TryDecode(Rgb420, 1 << 20, out var pixels, out var format, out var width, out var height)); + + Assert.Equal("Rgb24", format); + Assert.Equal(32, width); + Assert.Equal(32, height); + Assert.InRange(MaxDifference(pixels!, Rgb420Expected), 0, 6); + } + + /// + /// Four components are CMYK, one byte each. A JPEG stores them inverted — 0 is full ink, which is what + /// the image's /Decode array states — while Pillow converts them to the conventional 0 = no ink, + /// so the comparison inverts Pillow's bytes. + /// + [Fact] + public void Decodes_a_cmyk_jpeg_to_four_components() + { + Assert.True(JpegDecoder.TryDecode(Cmyk, 1 << 20, out var pixels, out var format, out var width, out var height)); + + var expected = CmykExpected.Select(value => (byte)(255 - value)).ToArray(); + + Assert.Equal("Cmyk32", format); + Assert.Equal(32, width); + Assert.Equal(32, height); + Assert.InRange(MaxDifference(pixels!, expected), 0, 2); + } + + /// Restart markers reset the predictors, and the decode continues after each of them. + [Fact] + public void Decodes_a_jpeg_with_restart_markers() + { + Assert.True(JpegDecoder.TryDecode(Restart, 1 << 20, out var pixels, out var format, out var width, out var height)); + + Assert.Equal("Rgb24", format); + Assert.Equal(32, width); + Assert.Equal(32, height); + Assert.InRange(MaxDifference(pixels!, RestartExpected), 0, 2); + } + + /// A pixel buffer above the budget is measured and nothing is kept. + [Fact] + public void Measures_a_pixel_buffer_above_the_budget_without_keeping_it() + { + Assert.True(JpegDecoder.TryDecode(Rgb, 16, out var pixels, out var format, out var width, out var height)); + + Assert.Equal("Rgb24", format); + Assert.Equal(16, width); + Assert.Null(pixels); + } + + /// A stream that is not a JPEG is refused. + [Fact] + public void Refuses_a_stream_that_is_not_a_jpeg() => + Assert.False(JpegDecoder.TryDecode(new byte[] { 0, 0, 0, 0 }, 1 << 20, out _, out _, out _, out _)); + + /// A JPEG that stops inside a segment is refused rather than decoded as far as it got. + [Fact] + public void Refuses_a_truncated_jpeg() + { + var truncated = Rgb.AsSpan(0, 24).ToArray(); + + Assert.False(JpegDecoder.TryDecode(truncated, 1 << 20, out _, out _, out _, out _)); + } + + private static int MaxDifference(ReadOnlySpan actual, ReadOnlySpan expected) + { + var maximum = 0; + + for (var index = 0; index < actual.Length; index++) + { + maximum = Math.Max(maximum, Math.Abs(actual[index] - expected[index])); + } + + return maximum; + } +}