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); } ///