mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 14:41:51 +00:00
M2: keep an interpolated soft mask smooth on a shrunk image's grid
A soft mask no larger than its area-shrunk image is sampled onto the image's grid, and it was always sampled nearest-neighbour: a mask that asks for /Interpolate became a hard step where main drew a ramp (jpeskov's 64 x 64 image at 32 x 32 under a 2 x 2 mask). An interpolated mask is now blended bilinearly onto the grid, centres aligned; one without /Interpolate is still sampled nearest, which keeps the colour-fringe fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ff227b770c
commit
5ed2f4ba4f
@@ -6734,6 +6734,35 @@ fn a_smaller_soft_mask_does_not_bleed_transparent_colour_when_minified() {
|
||||
assert!(r >= 254 && g.abs_diff(128) <= 1 && b == g, "half-covered red over white, got ({r}, {g}, {b})");
|
||||
}
|
||||
|
||||
/// A smaller soft mask that asks for `/Interpolate` stays smooth when it is sampled onto the grid
|
||||
/// of an area-shrunk image. Here a 64 × 64 black image is drawn at 32 × 32 under a 2 × 2 mask,
|
||||
/// opaque on the left and clear on the right: the mask is enlarged while the image shrinks, and
|
||||
/// sampled nearest-neighbour onto the image's grid the edge became a hard black and white step.
|
||||
#[test]
|
||||
fn an_interpolated_smaller_soft_mask_stays_smooth_when_its_image_is_minified() {
|
||||
let content = "q 32 0 0 32 0 0 cm /Im0 Do Q";
|
||||
let (image, mask) = ("00".repeat(64 * 64), "FF00FF00");
|
||||
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 32 32] /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 64 /Height 64 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 6 0 R /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\
|
||||
6 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true /Filter /ASCIIHexDecode /Length {} >> stream\n{mask}>\nendstream endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF\n",
|
||||
content.len(),
|
||||
image.len() + 1,
|
||||
mask.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), (32, 32));
|
||||
let row: Vec<u8> = page.rgba.as_chunks::<4>().0.iter().skip(16 * 32).take(32).map(|[r, ..]| *r).collect();
|
||||
let ramp = row.iter().filter(|v| (1..255).contains(*v)).count();
|
||||
assert!(ramp >= 8 && row.is_sorted() && row.first() == Some(&0) && row.last() == Some(&255), "a smooth black to white edge, got {row:?}");
|
||||
}
|
||||
|
||||
#[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]];
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+63
-11
@@ -193,27 +193,67 @@ fn image_buffer(len: usize) -> Option<Vec<u8>> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// PdfCraft patch: `mask` sampled onto the grid of a `width` × `height` image, each image sample
|
||||
/// taking the mask sample under its centre. `None` for an empty side or a buffer over the limits.
|
||||
/// PdfCraft patch: `mask` sampled onto the grid of a `width` × `height` image (no side smaller
|
||||
/// than the mask's). Each image sample takes the mask sample under its centre or, for a mask that
|
||||
/// asks for `/Interpolate`, the bilinear blend of the four around it, so the mask's edges stay as
|
||||
/// smooth as when it was enlarged on its own. `None` for an empty side, an image side smaller than
|
||||
/// the mask's, or a buffer over the limits.
|
||||
fn mask_on_image_grid(mask: &LumaData, width: u32, height: u32) -> Option<Vec<u8>> {
|
||||
if width == 0 || height == 0 || mask.width == 0 || mask.height == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut out = image_buffer(source_byte_len(width, height, 1, MAX_IMAGE_PIXELS)?)?;
|
||||
let mut columns = Vec::new();
|
||||
columns.try_reserve_exact(width as usize).ok()?;
|
||||
columns.extend((0..width).map(|x| nearest_source_index(mask.width, width, x)));
|
||||
let columns = grid_axis(mask.width, width, mask.interpolate)?;
|
||||
let rows = grid_axis(mask.height, height, mask.interpolate)?;
|
||||
let mask_width = mask.width as usize;
|
||||
for y in 0..height {
|
||||
let start = nearest_source_index(mask.height, height, y).checked_mul(mask_width)?;
|
||||
let row = mask.data.get(start..start.checked_add(mask_width)?)?;
|
||||
for &x in &columns {
|
||||
out.push(*row.get(x)?);
|
||||
let row = |y: usize| {
|
||||
let start = y.checked_mul(mask_width)?;
|
||||
mask.data.get(start..start.checked_add(mask_width)?)
|
||||
};
|
||||
let one = u64::from(GRID_ONE);
|
||||
for &(y0, y1, fy) in &rows {
|
||||
let (top, bottom, fy) = (row(y0)?, row(y1)?, u64::from(fy));
|
||||
for &(x0, x1, fx) in &columns {
|
||||
let fx = u64::from(fx);
|
||||
let blend = |r: &[u8]| Some(u64::from(*r.get(x0)?) * (one - fx) + u64::from(*r.get(x1)?) * fx);
|
||||
// At most 255 × GRID_ONE², so the sum fits in a u64 and the result in a u8.
|
||||
let v = (blend(top)? * (one - fy) + blend(bottom)? * fy + one * one / 2) / (one * one);
|
||||
out.push(v.min(255) as u8);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// PdfCraft patch: fixed-point unit of [`grid_axis`]'s blend weights.
|
||||
const GRID_ONE: u32 = 1 << 16;
|
||||
|
||||
/// PdfCraft patch: for each of `dst` samples (`dst >= src`) taken from `src` along one axis, the
|
||||
/// two source samples it lies between and the weight of the second, in [`GRID_ONE`] units.
|
||||
/// Centres line up: target `t` lies at `(t + ½)·src/dst − ½` source samples, held to the ends.
|
||||
/// Without `smooth` both are the nearest sample, so the weight does not matter.
|
||||
fn grid_axis(src: u32, dst: u32, smooth: bool) -> Option<Vec<(usize, usize, u32)>> {
|
||||
if src == 0 || dst < src {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(dst as usize).ok()?;
|
||||
let last = u64::from(src - 1) << 16;
|
||||
for t in 0..dst {
|
||||
if !smooth {
|
||||
let s = nearest_source_index(src, dst, t);
|
||||
out.push((s, s, 0));
|
||||
continue;
|
||||
}
|
||||
// In u128, as `(2t + 1)·src·2^16` can pass 2^64 for a long side; the quotient is at
|
||||
// most `src·2^16`, so it fits back in a u64.
|
||||
let centre = ((2 * u128::from(t) + 1) * u128::from(src) << 16) / (2 * u128::from(dst));
|
||||
let at = u64::try_from(centre).ok()?.saturating_sub(u64::from(GRID_ONE / 2)).min(last);
|
||||
let s = (at >> 16) as usize;
|
||||
out.push((s, (s + 1).min(src as usize - 1), (at & 0xFFFF) as u32));
|
||||
}
|
||||
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.
|
||||
@@ -2908,7 +2948,18 @@ mod area_shrink_tests {
|
||||
assert_eq!(mask_on_image_grid(&mask(&[10, 20], 2, 1), 4, 2), Some(vec![10, 10, 20, 20, 10, 10, 20, 20]));
|
||||
// Centres of 7 samples over 3: 0.5/7·3 = 0.21 → 0, 1.5/7·3 = 0.64 → 0, 2.5/7·3 = 1.07 → 1, …
|
||||
assert_eq!(mask_on_image_grid(&mask(&[1, 2, 3], 3, 1), 7, 1), Some(vec![1, 1, 2, 2, 2, 3, 3]));
|
||||
assert_eq!(mask_on_image_grid(&mask(&[1, 2, 3, 4], 1, 4), 1, 2), Some(vec![2, 4]));
|
||||
}
|
||||
|
||||
/// A mask that asks for `/Interpolate` is blended between the samples around each centre,
|
||||
/// and held at its ends: the centres of 4 samples over 2 lie at -¼, ¼, ¾ and 1¼.
|
||||
#[test]
|
||||
fn an_interpolated_mask_is_blended_onto_the_image_grid() {
|
||||
let smooth = |data: &[u8], width, height| LumaData { interpolate: true, ..mask(data, width, height) };
|
||||
assert_eq!(mask_on_image_grid(&smooth(&[0, 255], 2, 1), 4, 2), Some(vec![0, 64, 191, 255, 0, 64, 191, 255]));
|
||||
// Centres of 7 samples over 3 lie 3/7 apart, from -2/7 to 2 2/7: a ramp, flat at the ends.
|
||||
assert_eq!(mask_on_image_grid(&smooth(&[0, 70, 140], 3, 1), 7, 1), Some(vec![0, 10, 40, 70, 100, 130, 140]));
|
||||
assert_eq!(mask_on_image_grid(&smooth(&[0, 255], 1, 2), 1, 4), Some(vec![0, 64, 191, 255]));
|
||||
assert_eq!(mask_on_image_grid(&smooth(&[7, 9], 2, 1), 2, 1), Some(vec![7, 9]), "same size is unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2916,6 +2967,7 @@ mod area_shrink_tests {
|
||||
assert_eq!(mask_on_image_grid(&mask(&[], 0, 1), 4, 1), None, "an empty mask");
|
||||
assert_eq!(mask_on_image_grid(&mask(&[1, 2], 2, 1), 0, 1), None, "an empty image");
|
||||
assert_eq!(mask_on_image_grid(&mask(&[1], 2, 1), 4, 1), None, "mask data shorter than its size");
|
||||
assert_eq!(mask_on_image_grid(&mask(&[1, 2, 3, 4], 1, 4), 1, 2), None, "a mask larger than the image");
|
||||
}
|
||||
|
||||
/// Every target sample's weights add up to exactly one, and together the targets take every
|
||||
|
||||
Reference in New Issue
Block a user