diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4ffea78..fa14d44 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -37,8 +37,9 @@ against an empty feed.
- **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.
+ 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.
## [1.8.0] - 2026-11-14
diff --git a/docs/adr/0005-image-filter-decoders.md b/docs/adr/0005-image-filter-decoders.md
index ecd6323..8ba0209 100644
--- a/docs/adr/0005-image-filter-decoders.md
+++ b/docs/adr/0005-image-filter-decoders.md
@@ -132,10 +132,9 @@ 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.
+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.
## Status of the slices
@@ -143,6 +142,6 @@ are the next tests.
| --- | --- |
| `RunLengthDecode` | **Implemented** (2026-11-14) |
| `CCITTFaxDecode` | **Implemented** (2026-11-15) |
-| `DCTDecode` | **Baseline (SOF0/SOF1) implemented** (2026-11-15); progressive (SOF2) next |
+| `DCTDecode` | **Baseline (SOF0/SOF1) implemented, fancy upsampling** (2026-11-15); progressive (SOF2) next |
| `JPXDecode` | Planned |
| `JBIG2Decode` | Planned |
diff --git a/src/SharpCorePDF.Infrastructure/Pdf/JpegDecoder.cs b/src/SharpCorePDF.Infrastructure/Pdf/JpegDecoder.cs
index 64dce0b..8987af2 100644
--- a/src/SharpCorePDF.Infrastructure/Pdf/JpegDecoder.cs
+++ b/src/SharpCorePDF.Infrastructure/Pdf/JpegDecoder.cs
@@ -612,9 +612,16 @@ private bool BuildOutput(int maxBytes, out byte[]? pixels, out string? format)
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];
+ samples[component] = Sample(
+ source.Plane,
+ source.Stride,
+ source.Stride == 0 ? 0 : source.Plane.Length / source.Stride,
+ x,
+ y,
+ source.H,
+ source.V,
+ maxH,
+ maxV);
}
if (count == 1)
@@ -663,6 +670,61 @@ private bool BuildOutput(int maxBytes, out byte[]? pixels, out string? format)
return true;
}
+ ///
+ /// Reads one sample, upsampling a subsampled component with the 3:1 filter libjpeg calls fancy: an
+ /// even output position takes three parts of its sample and one of the neighbour before it, an odd one
+ /// three parts and one of the neighbour after it, rounded as libjpeg rounds.
+ ///
+ private static int Sample(
+ byte[] plane,
+ int stride,
+ int rows,
+ int x,
+ int y,
+ int horizontal,
+ int vertical,
+ int maxH,
+ int maxV)
+ {
+ if (horizontal == maxH && vertical == maxV)
+ {
+ return plane[(y * stride) + x];
+ }
+
+ var sx = (x * horizontal) / maxH;
+ var sy = (y * vertical) / maxV;
+ var fancyX = horizontal * 2 == maxH;
+ var fancyY = vertical * 2 == maxV;
+
+ if (!fancyX && !fancyY)
+ {
+ return plane[(sy * stride) + sx];
+ }
+
+ var left = Math.Max(sx - 1, 0);
+ var right = Math.Min(sx + 1, stride - 1);
+
+ var centre = Column(sx);
+ var before = Column(left);
+ var after = Column(right);
+
+ return x % 2 == 0
+ ? ((3 * centre) + before + 1) >> 2
+ : ((3 * centre) + after + 2) >> 2;
+
+ int Column(int column)
+ {
+ if (!fancyY)
+ {
+ return plane[(sy * stride) + column];
+ }
+
+ return y % 2 == 0
+ ? ((3 * plane[(sy * stride) + column]) + plane[(Math.Max(sy - 1, 0) * stride) + column] + 1) >> 2
+ : ((3 * plane[(sy * stride) + column]) + plane[(Math.Min(sy + 1, rows - 1) * stride) + column] + 2) >> 2;
+ }
+ }
+
private static byte Clamp(double value) =>
(byte)Math.Clamp((int)Math.Round(value), 0, 255);
diff --git a/tests/SharpCorePDF.Tests/PdfJpegFilterTests.cs b/tests/SharpCorePDF.Tests/PdfJpegFilterTests.cs
index ceedee4..79f2ecd 100644
--- a/tests/SharpCorePDF.Tests/PdfJpegFilterTests.cs
+++ b/tests/SharpCorePDF.Tests/PdfJpegFilterTests.cs
@@ -50,18 +50,18 @@ public void Decodes_a_baseline_gray_jpeg_within_two_levels()
}
///
- /// 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.
+ /// A 4:2:0 JPEG halves the chroma in both directions; the decoder upsamples with the same 3:1 filter
+ /// libjpeg (and so Pillow) uses, which keeps the comparison inside the two-level tolerance.
///
[Fact]
- public void Decodes_a_subsampled_jpeg_within_the_upsampling_tolerance()
+ public void Decodes_a_subsampled_jpeg_within_two_levels()
{
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);
+ Assert.InRange(MaxDifference(pixels!, Rgb420Expected), 0, 2);
}
///