M2: keep non-interpolated minified images sharp (#635)

* Fix #624: preserve hard edges for non-interpolated images

Select nearest-neighbour for Hayro's image pre-minification when /Interpolate is absent or false, retaining Catmull-Rom for true. Add a synthetic regression covering default, false, and true behaviour and record the vendored patch.

Co-authored-by: Codex <noreply@openai.com>

* M2: test non-interpolated minification with exact colours

The #624 regression used alternating black and white columns. Nearest 8→4 sampling reads only white columns from that pattern, so an image skipped entirely still passed, and the check only asked for near-black or near-white pixels.

Replace it with 2×2 colour blocks whose exact device output is asserted, with /Interpolate omitted equal to false, and /Interpolate true still blending. Add exact cases for gray, a non-integer 10→6 minification, and a matched soft mask. All pass on this head. On origin/main the same block image renders with blended mid-tones (for example 12eb12ff where pure green is expected), so these tests fail without the #624 change.

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

* M2: plan nearest-neighbour pre-resize without filter limits

Non-interpolated images are pre-resized with nearest-neighbour, which copies source pixels and needs no filter tables, crossed intermediate or scratch. The preflight still applied the Catmull-Rom checks to it: a source wider than 2^20 pixels, or a filter plan over 128 MiB, was refused, and once such a source was wider than the backend's u16 limit the image was skipped.

resampling_byte_len and image_resampling_size take the image's interpolation flag. Nearest keeps the source and target buffer bounds and refuses any scratch it reports. Filtered images keep their limits unchanged, and the existing image_resampling_size tests pass true.

Measured: a 1.5M-pixel-wide RGB row drawn at 750×4 device pixels renders 0 non-white pixels on origin/main (the image is skipped) and all 3000 pixels with this commit. The 1.1M-pixel gray case is covered by wide_non_interpolated_sources_draw_after_shrinking.

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

* M2: keep non-interpolated Type 3 glyph images nearest

Minified images inside Type 3 glyphs were drawn with ImageQuality::High, a bicubic sampler, whatever their /Interpolate. A non-interpolated image had already been pre-resized with nearest-neighbour, and bicubic sampling of that result blended it again. High is now used only for interpolated images, as elsewhere.

Before this commit the new test saw an interior glyph pixel of [28, 28, 27] (not a palette colour) for a 4.6 × 2.3 px glyph of a palette image; after it, every interior pixel is a palette colour. /Interpolate true still blends.

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

* M2: nearest-sample masked minified images straight into premultiplied RGBA

A non-interpolated image with a matching soft mask used to build a full-resolution RGBA copy, nearest-resize it, then premultiply the target. For a minified image most of that copy was discarded.

nearest_premultiplied_rgba reads colour and mask from the same source pixel, selected with PicScale's nearest index (same fixed-point centre sampling as ResampleNearestPlan) and premultiplies only the target pixels. It falls back to the previous path if its target buffer cannot be allocated. Interpolated images, unmasked images and magnification are unchanged.

New test: a ten-column gray ramp with a soft mask drawn six pixels wide must pick the same columns for colour and mask.

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

* M2: record the #624 image patch in vendor/README.md

The hayro row listed the #624 regression test but not the patch itself. Add patch item (10) covering the nearest pre-resize and its planning, the Type 3 bicubic restriction and the premultiplied soft-mask path, and list every regression test that covers them.

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

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
cyfung1031
2026-10-11 13:31:18 -04:00
committed by GitHub
co-authored by Codex Claude Haiku 5.5 Brandon Thomas
parent dda1a155fd
commit dce9b9f912
3 changed files with 337 additions and 34 deletions
+221 -19
View File
@@ -2099,48 +2099,59 @@ mod tests {
// Metadata-only: the test budget is 128 pixels and no image buffer is allocated.
#[test]
fn image_resampling_rejects_anisotropic_target_growth() {
assert_eq!(hayro::image_resampling_size(16, 1, 1, 16, 0.5, 32.0, 128), None);
assert_eq!(hayro::image_resampling_size(16, 1, 3, 48, 0.5, 32.0, 128), None);
assert_eq!(hayro::image_resampling_size(16, 1, 4, 64, 0.5, 32.0, 128), None);
assert_eq!(hayro::image_resampling_size(16, 1, 1, 16, 0.5, 32.0, 128, true), None);
assert_eq!(hayro::image_resampling_size(16, 1, 3, 48, 0.5, 32.0, 128, true), None);
assert_eq!(hayro::image_resampling_size(16, 1, 4, 64, 0.5, 32.0, 128, true), None);
}
#[test]
fn image_resampling_bounds_intermediate_before_planning() {
// Source and destination are each 128 pixels, but their crossed dimensions are 256.
assert_eq!(hayro::image_resampling_size(16, 8, 4, 512, 0.5, 2.0, 128), None);
assert_eq!(hayro::image_resampling_size(16, 8, 4, 512, 0.5, 2.0, 128, true), None);
}
#[test]
fn image_resampling_rejects_invalid_sources_and_scales() {
assert_eq!(hayro::image_resampling_size(4, 4, 3, 47, 0.5, 0.5, 128), None);
assert_eq!(hayro::image_resampling_size(4, 4, 3, 49, 0.5, 0.5, 128), None);
assert_eq!(hayro::image_resampling_size(4, 4, 3, 47, 0.5, 0.5, 128, true), None);
assert_eq!(hayro::image_resampling_size(4, 4, 3, 49, 0.5, 0.5, 128, true), None);
for scale in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0, 0.0] {
assert_eq!(hayro::image_resampling_size(4, 4, 3, 48, 0.5, scale, 128), None);
assert_eq!(hayro::image_resampling_size(4, 4, 3, 48, 0.5, scale, 128, true), None);
}
}
#[test]
fn image_resampling_keeps_valid_sizes_and_exact_limits() {
assert_eq!(hayro::image_resampling_size(16, 8, 1, 128, 0.5, 0.5, 128), Some((8, 4)));
assert_eq!(hayro::image_resampling_size(16, 1, 3, 48, 0.5, 8.0, 128), Some((8, 8)));
assert_eq!(hayro::image_resampling_size(16, 8, 4, 512, 1.0, 1.0, 128), Some((16, 8)));
assert_eq!(hayro::image_resampling_size(0, 8, 1, 0, 1.0, 1.0, 128), None);
assert_eq!(hayro::image_resampling_size(16, 9, 1, 144, 1.0, 1.0, 128), None);
assert_eq!(hayro::image_resampling_size(65_536, 1, 1, 65_536, 1.0, 1.0, u64::MAX), None);
assert_eq!(hayro::image_resampling_size(16, 8, 1, 128, 0.5, 0.5, 128, true), Some((8, 4)));
assert_eq!(hayro::image_resampling_size(16, 1, 3, 48, 0.5, 8.0, 128, true), Some((8, 8)));
assert_eq!(hayro::image_resampling_size(16, 8, 4, 512, 1.0, 1.0, 128, true), Some((16, 8)));
assert_eq!(hayro::image_resampling_size(0, 8, 1, 0, 1.0, 1.0, 128, true), None);
assert_eq!(hayro::image_resampling_size(16, 9, 1, 144, 1.0, 1.0, 128, true), None);
assert_eq!(hayro::image_resampling_size(65_536, 1, 1, 65_536, 1.0, 1.0, u64::MAX, true), None);
for channels in [1, 3, 4] {
assert_eq!(hayro::image_resampling_size(65_536, 1, channels, 65_536 * channels, 1.0 / 4096.0, 1.0, 65_536), Some((16, 1)));
assert_eq!(hayro::image_resampling_size(65_536, 1, channels, 65_536 * channels, 1.0 / 4096.0, 1.0, 65_536, true), Some((16, 1)));
}
assert_eq!(hayro::image_resampling_size(65_535, 1, 1, 65_535, 1.0, 1.0, 65_535), Some((65_535, 1)));
assert_eq!(hayro::image_resampling_size((1 << 20) + 1, 1, 1, (1 << 20) + 1, 1.0 / 4096.0, 1.0, 1 << 28), None);
assert_eq!(hayro::image_resampling_size(65_535, 1, 1, 65_535, 1.0, 1.0, 65_535, true), Some((65_535, 1)));
assert_eq!(hayro::image_resampling_size((1 << 20) + 1, 1, 1, (1 << 20) + 1, 1.0 / 4096.0, 1.0, 1 << 28, true), None);
}
#[test]
fn image_resampling_padded_backend_dimensions_stay_checked() {
// A Type 3 image's two-pixel frame may reach u16::MAX exactly, never wrap to zero.
assert_eq!(hayro::image_resampling_size(65_531 + 4, 1 + 4, 4, 65_535 * 5 * 4, 1.0, 1.0, 65_535 * 5), Some((65_535, 5)));
assert_eq!(hayro::image_resampling_size(65_531 + 4, 1 + 4, 4, 65_535 * 5 * 4, 1.0, 1.0, 65_535 * 5 - 1), None);
assert_eq!(hayro::image_resampling_size(65_532 + 4, 1 + 4, 4, 65_536 * 5 * 4, 1.0, 1.0, 1 << 28), None);
assert_eq!(hayro::image_resampling_size(65_531 + 4, 1 + 4, 4, 65_535 * 5 * 4, 1.0, 1.0, 65_535 * 5, true), Some((65_535, 5)));
assert_eq!(hayro::image_resampling_size(65_531 + 4, 1 + 4, 4, 65_535 * 5 * 4, 1.0, 1.0, 65_535 * 5 - 1, true), None);
assert_eq!(hayro::image_resampling_size(65_532 + 4, 1 + 4, 4, 65_536 * 5 * 4, 1.0, 1.0, 1 << 28, true), None);
}
/// Nearest-neighbour copies source pixels and needs no filter tables, so a source wider than the
/// Catmull-Rom side cap still shrinks. The pixel budget applies to it all the same.
#[test]
fn image_resampling_nearest_accepts_wide_sources_the_filter_refuses() {
let wide: u32 = (1 << 20) + 1;
assert_eq!(hayro::image_resampling_size(wide, 1, 1, wide as usize, 1.0 / 4096.0, 1.0, 1 << 28, true), None);
assert_eq!(hayro::image_resampling_size(wide, 1, 1, wide as usize, 1.0 / 4096.0, 1.0, 1 << 28, false), Some((257, 1)));
assert_eq!(hayro::image_resampling_size(wide, 1, 1, wide as usize, 1.0 / 4096.0, 1.0, 256, false), None);
}
fn strip_image_pdf(width: u32, body: &str, space: &str, encoded: &str, alpha: Option<&str>) -> Vec<u8> {
let (mask_ref, mask_obj) = match alpha {
Some(data) => (
@@ -2193,6 +2204,23 @@ mod tests {
assert_eq!(&page.rgba[(20 + 3) * 4..][..4], &[255, 128, 128, 255]);
}
/// A source wider than the filter's 2^20-pixel side cap is shrunk by nearest-neighbour rather
/// than refused and skipped (the default, `/Interpolate` omitted, is nearest).
#[test]
fn wide_non_interpolated_sources_draw_after_shrinking() {
let data = "7f".repeat(1_100_000);
let pdf = strip_image_pdf(1_100_000, "q 16 0 0 1 2 2 cm /Im0 Do Q\n", "DeviceGray", &data, None);
let mut renderer = PageRenderer::new(Arc::new(pdf), 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), (20, 4));
let pixel = |x: usize| &page.rgba[(20 + x) * 4..][..4];
assert_eq!(pixel(1), &[255, 255, 255, 255]);
assert_eq!(pixel(3), &[127, 127, 127, 255], "source image was not dropped");
assert_eq!(pixel(17), &[127, 127, 127, 255]);
assert_eq!(pixel(19), &[255, 255, 255, 255]);
}
#[test]
fn image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement() {
let data = "ff0000".repeat(16);
@@ -4501,6 +4529,180 @@ trailer << /Root 1 0 R >>
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255]);
}
/// Hex-encodes `bytes` for an `/ASCIIHexDecode` stream.
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// An 8×4 source of 2×2 blocks: block column `x / 2` and row `y / 2` takes `levels[(y / 2) * 4 + x / 2]`.
fn block_source<T: Copy>(levels: [T; 8]) -> Vec<T> {
(0..32).map(|i| levels[(i / 16) * 4 + (i % 8) / 2]).collect()
}
/// Draws one `size` image of colour space `space` (`data` as raw samples, with an optional gray
/// `/SMask` of the same size) at 1:1 on a `device`-sized page, writing `/Interpolate` when given.
fn draw_minified_image(
space: &str,
size: (u32, u32),
interpolate: Option<bool>,
data: &[u8],
alpha: Option<&[u8]>,
device: (u32, u32),
) -> RenderedPage {
let (width, height) = size;
let interp = interpolate.map_or_else(String::new, |value| format!("/Interpolate {value} "));
let (smask, mask) = match alpha {
Some(alpha) => (
"/SMask 6 0 R ",
format!(
"6 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length {} >> stream\n{}>\nendstream endobj\n",
hex(alpha).len() + 1,
hex(alpha)
),
),
None => ("", String::new()),
};
let image = hex(data);
let content = format!("q {} 0 0 {} 0 0 cm /Im0 Do Q", device.0, device.1);
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 {} {}] /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 {width} /Height {height} /ColorSpace /{space} /BitsPerComponent 8 {interp}{smask}/Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\
{mask}trailer << /Root 1 0 R >>\n%%EOF\n",
device.0,
device.1,
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), device);
page
}
/// Issue #624: an image drawn below its source resolution was pre-resampled with Catmull-Rom
/// even when `/Interpolate` was false (or absent, whose PDF default is false). That blends
/// neighboring samples before the renderer's later non-interpolating image sampler sees them,
/// making scans, screenshots and rasterized text look soft.
#[test]
fn non_interpolated_images_are_not_smoothed_when_minified() {
// Top band K W R G, bottom band B Y M C. Nearest 8×4 → 4×2 sampling picks each block's
// pixel, so every device pixel is one palette colour; Catmull-Rom blends across blocks.
let palette: [[u8; 3]; 8] = [[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255]];
let rgb: Vec<u8> = block_source(palette).concat();
let exact: Vec<[u8; 4]> = palette.iter().map(|c| [c[0], c[1], c[2], 255]).collect();
let pixels = |p: &RenderedPage| p.rgba.as_chunks::<4>().0.to_vec();
// `/Interpolate` defaults to false, and explicit false is equivalent.
let omitted = draw_minified_image("DeviceRGB", (8, 4), None, &rgb, None, (4, 2));
assert_eq!(pixels(&omitted), exact, "default interpolation");
let disabled = draw_minified_image("DeviceRGB", (8, 4), Some(false), &rgb, None, (4, 2));
assert_eq!(&omitted.rgba[..], &disabled.rgba[..], "/Interpolate false must match the default");
// The fix must not disable interpolation: the same source asking for it blends blocks.
let enabled = draw_minified_image("DeviceRGB", (8, 4), Some(true), &rgb, None, (4, 2));
assert_ne!(pixels(&enabled), exact, "/Interpolate true should still blend");
assert!(pixels(&enabled).iter().any(|px| px[..3].iter().any(|&c| (32..=223).contains(&c))), "/Interpolate true made no mid-tone");
}
#[test]
fn non_interpolated_gray_images_are_not_smoothed_when_minified() {
let levels = [0u8, 255, 85, 170, 40, 200, 120, 230];
let page = draw_minified_image("DeviceGray", (8, 4), None, &block_source(levels), None, (4, 2));
let expected: Vec<[u8; 4]> = levels.iter().map(|&v| [v, v, v, 255]).collect();
assert_eq!(page.rgba.as_chunks::<4>().0.to_vec(), expected);
}
#[test]
fn non_integer_minification_samples_the_same_columns() {
// Ten columns drawn six pixels wide: centre sampling reads columns 0, 2, 4, 5, 7 and 9.
let ramp = [10u8, 20, 30, 40, 50, 60, 70, 80, 90, 100];
let page = draw_minified_image("DeviceGray", (10, 1), None, &ramp, None, (6, 1));
let grays: Vec<u8> = page.rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect();
assert_eq!(grays, [10, 30, 50, 60, 80, 100]);
}
#[test]
fn non_integer_minification_samples_colour_and_mask_from_the_same_columns() {
// Ten gray columns with a soft mask, drawn six pixels wide: columns 0, 2, 4, 5, 7 and 9 are
// sampled, so the mask must pick the same columns as the colours.
let ramp = [10u8, 20, 30, 40, 50, 60, 70, 80, 90, 100];
let mask = [255u8, 0, 0, 0, 255, 255, 0, 0, 0, 255];
let page = draw_minified_image("DeviceGray", (10, 1), None, &ramp, Some(&mask), (6, 1));
// Opaque columns keep their gray; transparent columns show the white page.
assert_eq!(
page.rgba.as_chunks::<4>().0.to_vec(),
vec![[10, 10, 10, 255], [255, 255, 255, 255], [50, 50, 50, 255], [60, 60, 60, 255], [255, 255, 255, 255], [100, 100, 100, 255]]
);
}
#[test]
fn non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified() {
let palette: [[u8; 3]; 8] = [[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255]];
let rgb: Vec<u8> = block_source(palette).concat();
let alpha = block_source([255u8, 0, 128, 255, 0, 255, 64, 128]);
let page = draw_minified_image("DeviceRGB", (8, 4), None, &rgb, Some(&alpha), (4, 2));
// Premultiplied colours composited over the white page: alpha 0 shows the page.
let expected = vec![
[0, 0, 0, 255],
[255, 255, 255, 255],
[255, 127, 127, 255],
[0, 255, 0, 255],
[255, 255, 255, 255],
[255, 255, 0, 255],
[255, 191, 255, 255],
[127, 255, 255, 255],
];
assert_eq!(page.rgba.as_chunks::<4>().0.to_vec(), expected);
}
/// Type 3 glyph images used to be sampled bicubically (`ImageQuality::High`) whatever their
/// `/Interpolate`, which blends the nearest pre-resize back in. The glyph is 4.6 × 2.3 pixels
/// and its image pre-resizes to 5 × 3, so the final draw samples fractionally and bicubic
/// filtering would blend palette colours. Interior pixels must stay palette colours unless the
/// image asks for interpolation.
#[test]
fn non_interpolated_type3_glyph_images_are_not_smoothed_when_minified() {
let palette: [[u8; 3]; 8] = [[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255]];
let data = hex(&block_source(palette).concat());
let render = |interp: &str| {
let content = "BT /F1 1 Tf 0 0.7 Td (A) Tj ET";
let glyph = "4.6 0 d0 q 4.6 0 0 2.3 0 0 cm /Im0 Do Q";
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 5 3] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 10 10] /FontMatrix [1 0 0 1 0 0] /FirstChar 65 /LastChar 65 /Widths [5] /Encoding << /Differences [65 /a] >> /CharProcs << /a 6 0 R >> /Resources << /XObject << /Im0 7 0 R >> >> >> endobj\n\
6 0 obj << /Length {} >> stream\n{glyph}\nendstream endobj\n\
7 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 {interp}/Filter /ASCIIHexDecode /Length {} >> stream\n{data}>\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF\n",
content.len(),
glyph.len(),
data.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), (5, 3));
page
};
// The glyph fully covers columns 0–3 and rows 0–1.
let interior = |p: &RenderedPage| -> Vec<[u8; 3]> {
(0..2).flat_map(|y| (0..4).map(move |x| (y * 5 + x) * 4)).map(|i| [p.rgba[i], p.rgba[i + 1], p.rgba[i + 2]]).collect()
};
let omitted = render("");
for px in interior(&omitted) {
assert!(palette.contains(&px), "non-interpolated glyph image was smoothed to {px:?}");
}
let enabled = render("/Interpolate true ");
assert!(interior(&enabled).iter().any(|px| !palette.contains(px)), "/Interpolate true glyph image should still be sampled bicubically");
}
/// From `cargo xtask fuzz`: a CID font whose /W range spans every u32 inserted billions of
/// widths (vendored hayro-interpret patch: `MAX_CID`). Must finish quickly.
#[test]
+1
View File
File diff suppressed because one or more lines are too long
+115 -15
View File
@@ -115,17 +115,23 @@ fn resampling_scratch_byte_len(
/// PdfCraft patch: validate output, crossed intermediate and filter-work bounds before planning.
/// Source width × target height also bounds PicScale's smaller single-threaded four-row scratch.
/// Nearest-neighbour (`!interpolate`) has none of those: it copies source pixels into the target,
/// so only the source and target buffers are bounded.
fn resampling_byte_len(
source: (u32, u32),
target: (u32, u32),
channels: usize,
data_len: usize,
max_pixels: u64,
interpolate: bool,
) -> Option<usize> {
if source_byte_len(source.0, source.1, channels, max_pixels)? != data_len {
return None;
}
let target_len = image_byte_len(target.0, target.1, channels, max_pixels)?;
if !interpolate {
return Some(target_len);
}
resampling_scratch_byte_len(source, target, channels, max_pixels)?;
source_byte_len(source.0, target.1, channels, max_pixels)?;
if source.0 > MAX_RESAMPLING_SOURCE_SIDE || source.1 > MAX_RESAMPLING_SOURCE_SIDE {
@@ -142,6 +148,7 @@ fn resampling_byte_len(
/// PdfCraft patch: image dimensions selected before allocating resampling buffers.
/// Exported for PdfCraft's small, metadata-only regression tests. `None` rejects invalid
/// sources/scales or an over-budget target/intermediate; the caller may retain a valid source.
/// `interpolate` selects the Catmull-Rom limits; nearest-neighbour (`false`) skips them.
#[doc(hidden)]
pub fn image_resampling_size(
width: u32,
@@ -151,6 +158,7 @@ pub fn image_resampling_size(
x_scale: f32,
y_scale: f32,
max_pixels: u64,
interpolate: bool,
) -> Option<(u32, u32)> {
if !x_scale.is_finite() || !y_scale.is_finite() || x_scale <= 0.0 || y_scale <= 0.0 {
return None;
@@ -166,7 +174,14 @@ pub fn image_resampling_size(
} else {
(width, height)
};
resampling_byte_len((width, height), target, channels, data_len, max_pixels)?;
resampling_byte_len(
(width, height),
target,
channels,
data_len,
max_pixels,
interpolate,
)?;
Some(target)
}
@@ -177,6 +192,49 @@ fn image_buffer(len: usize) -> Option<Vec<u8>> {
Some(out)
}
/// PdfCraft patch (#624): the source index that PicScale's nearest-neighbour plan copies for
/// destination `index` of `dst` samples taken from `src`. It is the same fixed-point centre
/// sampling as `ResampleNearestPlan`, so these are the samples a nearest resize would copy.
fn nearest_source_index(src: u32, dst: u32, index: u32) -> usize {
let step = (u64::from(src) << 32) / u64::from(dst);
((u64::from(index) * step + (step >> 1)) >> 32) as usize
}
/// PdfCraft patch (#624): nearest-resizes a colour image and its same-sized soft mask straight
/// into premultiplied RGBA of `target` size. Colour and mask come from the same source pixel, so
/// this equals a nearest resize of the expanded RGBA followed by premultiplying each pixel.
fn nearest_premultiplied_rgba(
color: &ImageData,
alpha: &[u8],
source: (u32, u32),
target: (u32, u32),
) -> Option<Vec<u8>> {
let (samples, channels) = match color {
ImageData::Luma(luma) => (luma.data.as_slice(), 1),
ImageData::Rgb(rgb) => (rgb.data.as_slice(), 3),
};
let mut out = image_buffer(image_byte_len(target.0, target.1, 4, MAX_IMAGE_PIXELS)?)?;
for ty in 0..target.1 {
let sy = nearest_source_index(source.1, target.1, ty);
for tx in 0..target.0 {
let si = sy * source.0 as usize + nearest_source_index(source.0, target.0, tx);
let [r, g, b] = match samples.get(si * channels..si * channels + channels)? {
[v] => [*v; 3],
[r, g, b] => [*r, *g, *b],
_ => return None,
};
let a = *alpha.get(si)?;
out.extend_from_slice(
&AlphaColor::from_rgba8(r, g, b, a)
.premultiply()
.to_rgba8()
.to_u8_array(),
);
}
}
Some(out)
}
/// PdfCraft patch: most pieces one dashed stroke may be cut into. Stroke expansion keeps every
/// dash, so a pattern that is tiny next to its path asks for billions: a fuzzed `/D [[11] 0]` on
/// a line from x = 92234775807 allocated over 5 GB, and `[0.000001] 0 d` on a 20 pt line over
@@ -483,9 +541,19 @@ impl Renderer {
new_width: u32,
new_height: u32,
pixel_format: ImagePixelFormat,
interpolate: bool,
) -> (Vec<u8>, u32, u32) {
// PdfCraft patch (#624): `/Interpolate` governs the pre-minification pass too. Using
// Catmull-Rom here for an image that did not request interpolation blends hard edges
// before ImageQuality::Low gets a chance to sample it. Keep the pre-resize (and its
// bounded memory/performance behaviour), but use nearest-neighbour when interpolation
// is disabled. PDF's default for `/Interpolate` is false.
let nearest = Scaler::new(ResamplingFunction::Nearest);
let scaler = if interpolate { &self.scaler } else { &nearest };
let resized = match pixel_format {
ImagePixelFormat::Luma => self.resize_image_data_impl::<1>(
scaler,
interpolate,
data,
src_width,
src_height,
@@ -496,6 +564,8 @@ impl Renderer {
},
),
ImagePixelFormat::Rgb => self.resize_image_data_impl::<3>(
scaler,
interpolate,
data,
src_width,
src_height,
@@ -506,6 +576,8 @@ impl Renderer {
},
),
ImagePixelFormat::Rgba => self.resize_image_data_impl::<4>(
scaler,
interpolate,
data,
src_width,
src_height,
@@ -529,6 +601,8 @@ impl Renderer {
fn resize_image_data_impl<const N: usize>(
&self,
scaler: &Scaler,
interpolate: bool,
data: Vec<u8>,
src_width: u32,
src_height: u32,
@@ -549,19 +623,25 @@ impl Renderer {
N,
data.len(),
MAX_IMAGE_PIXELS,
interpolate,
)?;
let source_size = ImageSize::new(src_width as usize, src_height as usize);
let target_size = ImageSize::new(new_width as usize, new_height as usize);
let src = ImageStore::<u8, N>::from_slice(&data, source_size.width, source_size.height)
.ok()?;
let plan = plan(&self.scaler, source_size, target_size).ok()?;
let plan = plan(scaler, source_size, target_size).ok()?;
let scratch_len = plan.scratch_size();
let scratch_bound = resampling_scratch_byte_len(
(src_width, src_height),
(new_width, new_height),
N,
MAX_IMAGE_PIXELS,
)?;
// Nearest-neighbour needs no scratch, so any scratch it reports is refused.
let scratch_bound = if interpolate {
resampling_scratch_byte_len(
(src_width, src_height),
(new_width, new_height),
N,
MAX_IMAGE_PIXELS,
)?
} else {
0
};
if scratch_len > scratch_bound {
return None;
}
@@ -643,6 +723,7 @@ impl Renderer {
x_scale,
y_scale,
MAX_IMAGE_PIXELS,
interpolate,
) {
Some(target) => target,
None => {
@@ -658,17 +739,33 @@ impl Renderer {
(source_width, source_height)
}
};
if requested_resize && self.in_type3_glyph {
// PdfCraft patch (#624): High is bicubic and would blend the nearest pre-resize of an image
// that did not request interpolation, so only interpolated glyph images are promoted.
if requested_resize && self.in_type3_glyph && interpolate {
quality = ImageQuality::High;
}
let needs_resize = (new_width, new_height) != (source_width, source_height);
// PdfCraft patch (#624): a minified image with a soft mask that is not interpolated is
// nearest-sampled straight into premultiplied target RGBA. That skips the full-resolution
// RGBA copy below and the premultiply pass after it.
let direct = match &alpha_data {
Some(alpha) if needs_resize && !interpolate => nearest_premultiplied_rgba(
&image_data,
&alpha.data,
(source_width, source_height),
(new_width, new_height),
),
_ => None,
};
let premultiplied = direct.is_some();
// Preserve the single-channel/RGB fast paths. With alpha, expand directly to RGBA;
// every source and alpha length was validated before zipping or allocating.
let (data, format) = match (image_data, alpha_data) {
(ImageData::Luma(luma), None) => (luma.data, ImagePixelFormat::Luma),
(ImageData::Rgb(rgb), None) => (rgb.data, ImagePixelFormat::Rgb),
(image, Some(alpha)) => {
let (data, format) = match (direct, image_data, alpha_data) {
(Some(rgba), _, _) => (rgba, ImagePixelFormat::Rgba),
(None, ImageData::Luma(luma), None) => (luma.data, ImagePixelFormat::Luma),
(None, ImageData::Rgb(rgb), None) => (rgb.data, ImagePixelFormat::Rgb),
(None, image, Some(alpha)) => {
let Some(len) = source_byte_len(source_width, source_height, 4, MAX_IMAGE_PIXELS)
else {
return;
@@ -702,7 +799,9 @@ impl Renderer {
(rgba, ImagePixelFormat::Rgba)
}
};
let (data, mut img_width, mut img_height) = if needs_resize {
let (data, mut img_width, mut img_height) = if premultiplied {
(data, new_width, new_height)
} else if needs_resize {
self.resize_image_data(
data,
source_width,
@@ -710,6 +809,7 @@ impl Renderer {
new_width,
new_height,
format,
interpolate,
)
} else {
(data, source_width, source_height)
@@ -746,7 +846,7 @@ impl Renderer {
}
rgba
};
if has_alpha {
if has_alpha && !premultiplied {
let (chunks, _) = rgba_data.as_chunks_mut::<4>();
for chunk in chunks {
let [r, g, b, a] = *chunk;