M2: snap shrunk interpolated images to whole pixels so scans stay sharp (#1377)

* M2: snap shrunk interpolated images to whole pixels so scans stay sharp

A scanned page (one 300 dpi image with /Interpolate true) was shrunk to a
ceil-sized raster and then sampled a second time at a fractional scale and
offset, which softened every edge next to Acrobat. An axis-aligned shrunk
interpolated image is now shrunk once to its rounded device rectangle and
drawn 1:1 at the rounded corner. Rotated, skewed, flipped, Type 3 and
non-interpolated images are unchanged.

On a synthetic scan page the mean error against an area-average reference
falls from 3.4 to 1.6 grey levels at 96 dpi (3.0 to 1.5 at 144 dpi).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* vendor: list the hayro snap patch as (14) in the Patches and Tests columns; correct gaps.md patch counts

The patch description had been appended to the Tests column of one of the
two hayro rows. gaps.md now counts the distinct patches vendor/README.md
lists (26 hayro-interpret, 15 hayro, 3 hayro-jbig2, 17 hayro-syntax).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
philip-tam
2026-10-12 07:12:02 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 186c1dc767
commit 88d96c93a8
5 changed files with 85 additions and 9 deletions
+35
View File
@@ -4741,6 +4741,41 @@ trailer << /Root 1 0 R >>
assert!(pixels(&enabled).iter().any(|px| px[..3].iter().any(|&c| (32..=223).contains(&c))), "/Interpolate true made no mid-tone");
}
/// A shrunk, interpolated image whose edges fall between device pixels is snapped to whole
/// pixels (shrunk once to that size, drawn 1:1). Before, the shrunk raster was sampled a second
/// time at the fractional offset, so every edge of a scan was a blended, soft pixel.
#[test]
fn interpolated_minified_image_edges_snap_to_whole_pixels() {
let black = vec![0u8; 40 * 40];
let content = "q 10 0 0 10 0.4 0.4 cm /Im0 Do Q";
let image = hex(&black);
let pdf = format!(
"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 11 11] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 40 /Height 40 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF\n",
content.len(),
image.len() + 1
);
let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (11, 11));
let gray = |x: usize, y: usize| page.rgba[(y * 11 + x) * 4];
// Device box 0.4..10.4 × 0.6..10.6 snaps to columns 0..10 and rows 1..11.
for y in 1..11 {
for x in 0..10 {
assert_eq!(gray(x, y), 0, "pixel ({x}, {y}) inside the snapped image");
}
assert_eq!(gray(10, y), 255, "column 10 is outside the snapped image (row {y})");
}
for x in 0..11 {
assert_eq!(gray(x, 0), 255, "row 0 is outside the snapped image (column {x})");
}
}
#[test]
fn non_interpolated_gray_images_are_not_smoothed_when_minified() {
let levels = [0u8, 255, 85, 170, 40, 200, 120, 230];