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
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,16 @@ against an empty feed.
keeps the stored JPEG and `DecodedPixels` carries the samples, so a caller can have either. Baseline and
extended sequential (SOF0/SOF1) are decoded, and a subsampled component is upsampled with libjpeg's
3:1 fancy filter, so a 4:2:0 fixture matches a reference decoder within two levels. Progressive (SOF2)
is the next step, per ADR-005.
followed in the next entry.
- **Progressive JPEG images are decoded.** `DCTDecode` now reads the multi-scan form too: spectral
selection and successive approximation accumulate in the quantised coefficient grids the scans share,
and the inverse DCT runs once at the end, so a non-interleaved AC scan (one component) and an
interleaved DC scan (every component, MCU order) land on the same blocks. Seven progressive fixtures —
4:4:4, 4:2:0, grayscale, CMYK, a non-square 32 x 16 image, a sparse one that uses the EOB and zero runs,
and one with 150 restart markers — each matches Pillow within two levels of every sample. The AC
refinement's two conventions — the sign bit is read before the correction bits, and 1 means positive —
are recorded in [`ADR-005`](docs/adr/0005-image-filter-decoders.md), because getting either backwards
still decodes every scan and leaves a handful of coefficients a step or two out.

## [1.8.0] - 2026-11-14

Expand Down
28 changes: 22 additions & 6 deletions docs/adr/0005-image-filter-decoders.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# ADR-005: Decoding the image filters the reader passes through

- **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
`DCTDecode` (baseline 2026-11-15, progressive 2026-11-15) implemented; `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 @@ -42,7 +42,7 @@ Shipping order and acceptance criteria:
| --- | --- | --- |
| `RunLengthDecode` | — | **Done.** Literal and repeat runs, an optional end-of-data byte, a truncated stream decoded as far as it goes. `PdfImageFilterTests` and the `FilterChains` fixture pin it, and the image is exposed as `Gray8`. |
| `CCITTFaxDecode` | Group 3 one-dimensional (`/K` = 0), Group 3 two-dimensional (`/K` > 0), Group 4 (`/K` < 0); `/Columns`, `/Rows`, `/BlackIs1`, `/EncodedByteAlign`, `/EndOfLine` | The decoded bilevel bits equal an independent decoder's output on the same bytes. Evidence: Pillow's CCITT reader through `pikepdf` on the fixture, and a real fax sample in `tests/.../Assets/parity/images`. |
| `DCTDecode` | SOF0, SOF1, SOF2; restart markers; 1, 3 and 4 components; 4:4:4, 4:2:2, 4:2:0; YCbCr and CMYK/YCCK (Adobe APP14); EXIF orientation | Geometry and component count exact; every sample within **2 levels** of a reference decoder (an integer IDCT is allowed to differ by rounding). Evidence: Pillow on the `parity/images/jpeg-*.jpg` fixtures, compared pixel by pixel, with the per-image maximum recorded in `docs/mutation-testing.md`'s sibling measurement note. |
| `DCTDecode` | SOF0, SOF1, SOF2; restart markers; 1, 3 and 4 components; 4:4:4, 4:2:2, 4:2:0; YCbCr and CMYK/YCCK (Adobe APP14); EXIF orientation | Geometry and component count exact; every sample within **2 levels** of a reference decoder (an integer IDCT is allowed to differ by rounding). Evidence: Pillow on the `tests/SharpCorePDF.Tests/Assets/jpeg/jpeg-*.jpg` fixtures — baseline 4:4:4, grayscale, 4:2:0, CMYK and restart, plus progressive 4:4:4, 4:2:0, grayscale, CMYK, non-square, sparse and restart-interval — compared pixel by pixel, with the per-image maximum recorded in `docs/mutation-testing.md`. |
| `JPXDecode` | JP2 and raw J2K codestream; 5/3 reversible and 9/7 irreversible; tiles and tile-parts | Reversible path **bit-exact**; irreversible path within 2 levels. Evidence: `opj_decompress` on the same codestream. |
| `JBIG2Decode` | Generic region, symbol dictionary, text region, MMR | Bit-exact bilevel output. Evidence: `jbig2dec` on the same stream. |

Expand Down Expand Up @@ -127,21 +127,37 @@ document holds — and fills `DecodedPixels` from the new decoder, so a caller t
and a caller that wants pixels gets them. `ImagePixels` stops excluding `DCTDecode` once `TryDecodeJpeg` is
wired; the budget rules then apply to the pixel buffer as they do to every other decoded filter.

Order: SOF0 (baseline) first, against these fixtures; SOF1/SOF2 (extended and progressive) after it, with
Order: SOF0 (baseline) first, against these fixtures; SOF1 (extended) and SOF2 (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, decoded with libjpeg's 3:1 fancy
filter and within two levels of Pillow), `jpeg-cmyk.jpg` (four components, inverted as the `/Decode` array
states) and `jpeg-restart.jpg` (restart markers). SOF2 is the next slice.
states) and `jpeg-restart.jpg` (restart markers).

The progressive decoder followed on 2026-11-15: the scans' quantised coefficients are accumulated in one
grid per component and the inverse DCT runs once at the end, so a non-interleaved AC scan (one component,
its blocks) and an interleaved DC scan (every component, MCU order) both land on the same blocks. Seven
fixtures pin it — `jpeg-progressive.jpg` (32 x 32, quality 95, 4:4:4), `jpeg-progressive-420.jpg` (4:2:0),
`jpeg-progressive-gray.jpg` (one component), `jpeg-progressive-cmyk.jpg` (four components), and
`jpeg-progressive-wide.jpg` (32 x 16, so the block counts differ), `jpeg-progressive-sparse.jpg` (flat
areas, so the EOB runs and zero runs are used) and `jpeg-progressive-restart.jpg` (150 restart markers,
so the predictors and the EOB run reset) — each compared with Pillow's decode within two levels. The
truncated-stream theory cuts the entropy data at nine points and pins the refusals.
Two details cost a debugging round and are written down because a first implementation gets both wrong
without failing: in an AC refinement scan the sign bit of a new coefficient is read **before** the
correction bits of the coefficients the run passes over, and a sign bit of **1 means positive** (0 means
negative) — the inverse of the magnitude-field convention an AC first scan uses. Getting either backwards
decodes every scan to the end and leaves a handful of coefficients one or two steps out, which is why the
acceptance is a pixel comparison and not "it decoded".

## Status of the slices

| Slice | State |
| --- | --- |
| `RunLengthDecode` | **Implemented** (2026-11-14) |
| `CCITTFaxDecode` | **Implemented** (2026-11-15) |
| `DCTDecode` | **Baseline (SOF0/SOF1) implemented, fancy upsampling** (2026-11-15); progressive (SOF2) next |
| `DCTDecode` | **Baseline (SOF0/SOF1) and progressive (SOF2) implemented, fancy upsampling** (2026-11-15); `JPXDecode` 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, 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.
ASCII encodings, run-length, the CCITT fax codings (Group 3 and Group 4), and JPEG — baseline and
progressive — 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 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 `DCTDecode` image whose buffer is above the budget | `IsDecodable` true, `Filter` `DCTDecode`, `PixelFormat` null | A baseline or progressive JPEG is decoded to `Gray8`, `Rgb24` or `Cmyk32`; 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
62 changes: 60 additions & 2 deletions docs/mutation-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,64 @@ The survivors are the **variants the two fixtures do not carry**, not statements
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.
the score above predates them. The fancy upsampling that matches Pillow at every edge followed, and the
progressive variant's reading is below.

## DCTDecode — progressive (SOF2) (2026-11-15)

Scoped run over `Pdf/JpegDecoder.cs` after the progressive scans landed. The command is the 5.0.0
project-mode form with a copy of `stryker-config.json` that restores the `test-projects` entry, because
5.1.0 in this environment ignores the scope (the note at the end of this section has the evidence):

```powershell
"%TEMP%\stryker-tools\dotnet-stryker.exe" --config-file stryker-jpeg-config.json `
--project src\SharpCorePDF.Infrastructure\SharpCorePDF.Infrastructure.csproj `
--mutate "**/JpegDecoder.cs" --output artifacts\mutation\jpeg-progressive
```

20,004 mutants are created for the solution and the filter leaves **839** for the file (710 killed, 127
survived, 2 timeouts). The report is per file, so the whole decoder is read, not only the new region:

| File | Score | Killed | Survived | No coverage | Mutants |
| --- | --- | --- | --- | --- | --- |
| `Pdf/JpegDecoder.cs` (baseline, 2026-11-15) | 61.65% | 351 | 114 | 105 | 671 |
| `Pdf/JpegDecoder.cs` (progressive, before the coverage fixtures) | 70.23% | 650 (+1 timeout) | 146 | 130 | 1122 |
| `Pdf/JpegDecoder.cs` (progressive, after them) | **76.81%** | 710 (+2 timeouts) | 127 | 88 | 1122 |

The coverage fixtures the first reading asked for — the restart interval, the sparse image, the CMYK file
and the non-square grid — and the truncated-stream theory moved the new region (line 520 onwards) from
**72.0% to 82.5%**: 471 killed, 46 survived, 54 without coverage, 71 compile errors and 72 ignored. What
remains is the boundary and error end of the decoder rather than a scan that is not decoded:

- **The DC size and the end-of-band boundaries** (L689, L700, L953, L960, L975, L998): a valid stream
whose DC difference needs eleven bits, or whose last coefficient sits exactly on `Se`, is the missing
variant. The fixtures' DC differences are small and their bands end before the last coefficient.
- **The DC refinement at Al = 0** (L719): `|=` and `^=` agree while the bit is clear and `1 >> 0` is `1`,
so the fixture cannot tell them apart. A stream that refines the DC at `Ah = 2, Al = 1` does.
- **The YCCK transform** (L1093–L1115): `jpeg-progressive-cmyk.jpg` is a plain CMYK file (Adobe transform
0); the YCCK branch (transform 2) has no fixture. The baseline record's CMYK note has the same gap.
- **The upsampling boundaries** (L1145–L1180): the 4:2:0 fixtures take the H = V = 2 path, so a 4:2:2
image or an odd width would exercise `horizontal == maxH`/`vertical == maxV` and the edge column. The
`x / horizontal` arithmetic survives because the fixtures' chroma is a whole multiple of the luma.
- **The null-table and budget boundaries** (L946, L1055): a stream naming a table it never defined, and an
image exactly at the byte budget.
- **The bit reader's marker branches** (L1264–L1327): the truncation theory reaches some of them; a
stuffed `FF 00` or a marker inside entropy data reaches the rest. The `symbol >>> 4` and `>>> 8`
mutations are equivalent — a symbol comes from a byte and is never negative.
- **The baseline marker and segment edge cases** (L82–L248) are unchanged from the baseline reading.

None of the survivors is a statement to remove: each is a variant or a boundary the fixtures do not state,
and they are recorded rather than papered over. The two conventions the progressive decoder had to get
right — the sign bit before the correction bits, and 1 meaning positive — are killed by the progressive
fixtures, which is why they are written down in ADR-005.

**Harness note (5.1.0).** On this workstation, `dotnet stryker` 5.1.0 in solution context mutates the whole
tree however the scope is stated: the `mutate` entry in `stryker-config.json`, `--mutate` and `--since:main`
all leave 23,042 mutants, and the documented 5.1.0 example (`--mutate "**/PdfDiagnoserService.cs"`, 13
mutants in the 5.1.0 record above) reads the whole tree here. Project mode is refused with "Test projects
cannot be specified when running Stryker in solution context". The reading above is therefore taken with a
local 5.0.0 — `dotnet tool install dotnet-stryker --version 5.0.0 --tool-path %TEMP%\stryker-tools` — run
from the repository root with `--project` and the command-line `--mutate`, which applies the filter (839 of
20,004). The pin, the config and the workflow are unchanged; the version is stated because 5.1.0 rejected
one mutant at compile time in the earlier comparison, so a 5.1.0 re-run may move a point either way.

Loading
Loading