Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Binary file modified docs/SharpCorePDF-Manual-1.8.0.pdf
Binary file not shown.
14 changes: 11 additions & 3 deletions docs/adr/0005-image-filter-decoders.md
Original file line number Diff line number Diff line change
@@ -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),
Expand Down Expand Up @@ -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 |
6 changes: 3 additions & 3 deletions docs/manual/src/08-inspection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. |

Expand Down
28 changes: 28 additions & 0 deletions docs/mutation-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

2 changes: 1 addition & 1 deletion docs/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <anchor.pem> <signed.pdf>` (pyHanko, T093) and `uv run --with pikepdf python docs/tools/validate-pdf.py <signed.pdf>` | 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 <verapdf.bat> --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 <exports> --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 |
Expand Down
Loading
Loading