M2: shrink masked images a row at a time; keep near-1:1 scans sharp

Review of #3858 (aiongg):

- An area-shrunk image with a soft mask, a stencil's ink included,
  built its full-resolution RGBA before shrinking. A 10718 x 13701
  stencil at page size peaked at 1.16 GB (587 MB of RGBA). It is now
  premultiplied a source row at a time as it is averaged, and the
  shrink works a target row at a time, so it peaks at 1.03 GB, as the
  nearest-neighbour path did. Same bytes; about 5% slower per shrink.
- An image whose f32 scale passed 1 only by rounding was smoothed
  bilinearly and blurred by half a pixel at a half-pixel offset. It is
  now smoothed only above 1.01.
- A shrunk /ImageMask stencil keeping its thin strokes (#4800) is now
  tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kronbii
2026-11-04 11:32:52 +02:00
co-authored by Claude Opus 5.5
parent 5ed2f4ba4f
commit c5065554a1
4 changed files with 267 additions and 47 deletions
+53
View File
@@ -6814,6 +6814,59 @@ fn shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes() {
assert_eq!(grays[3], 255, "{grays:?}");
}
/// Issue #4800: scanners save the text of a page as a 1-bit `/ImageMask` stencil, painted in the
/// fill colour, and a stencil shrunk by nearest-neighbour lost the strokes thinner than the shrink
/// factor. Here a one-column red stroke of an 8-column stencil is drawn 4 pixels wide: nearest
/// reads columns 1, 3, 5 and 7 and misses it, while the area average makes it a half-covered red.
#[test]
fn a_shrunk_stencil_mask_keeps_thin_strokes() {
// Sample 0 paints (the default `/Decode [0 1]`), so 0xDF is a stroke in column 2.
let content = "q 1 0 0 rg 4 0 0 1 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 4 1] /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 8 /Height 1 /ImageMask true /Filter /ASCIIHexDecode /Length 3 >> stream\nDF>\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF\n",
content.len()
);
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), (4, 1));
let px = page.rgba.as_chunks::<4>().0.to_vec();
let [r, g, b, _] = px.get(1).copied().unwrap_or_default();
assert!(r >= 254 && g.abs_diff(128) <= 1 && b == g, "half-covered red over white, got {px:?}");
assert_eq!(px.get(3), Some(&[255, 255, 255, 255]), "{px:?}");
}
/// A scan drawn at its own resolution keeps its hard pixels when its scale passes 1 only by
/// rounding: `x_scale` is an `f32`, and a 192 dpi scan on a 2× display at 100% can come out a
/// few ulps above 1. Smoothed bilinearly at a half-pixel offset, one-pixel stripes became gray.
/// Here `600.0001` (an `f32` of 600.00012) draws 600 samples 2·10⁻⁷ wider than 1:1.
#[test]
fn a_scan_drawn_a_rounding_error_above_its_resolution_stays_sharp() {
let content = "q 600.0001 0 0 600 0.5 0 cm /Im0 Do Q";
let image: String = (0..600 * 600).map(|i| if i % 2 == 0 { "00" } else { "FF" }).collect();
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 601 600] /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 600 /Height 600 /ColorSpace /DeviceGray /BitsPerComponent 8 /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);
let row: Vec<u8> = page.rgba.as_chunks::<4>().0.iter().skip(300 * page.width as usize).take(page.width as usize).map(|[r, ..]| *r).collect();
let gray = row.iter().skip(10).take(580).filter(|v| (16..240).contains(*v)).count();
assert_eq!(gray, 0, "one-pixel stripes were blurred: {:?}", row.get(10..30));
}
/// 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
+2 -2
View File
@@ -734,8 +734,8 @@ milestone = "M2"
status = "partial"
commands = []
tools = ["page_render"]
evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it", "crates/render/src/raster/tests.rs::non_interpolated_minification_keeps_thin_strokes", "crates/render/src/raster/tests.rs::minified_image_edges_snap_to_whole_pixels", "crates/render/src/raster/tests.rs::large_non_interpolated_images_are_smoothed_when_enlarged", "crates/render/src/raster/tests.rs::small_non_interpolated_images_keep_hard_pixels_when_enlarged"]
notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value. An image drawn below its resolution is shrunk once to whole device pixels: by area averaging unless it asks for /Interpolate (then Catmull-Rom), so the text of a 300 dpi scan at 100% keeps its strokes (it was nearest-neighbour, which dropped them). Enlarged, an image of page-scan size (512 samples or more on its shorter side) is smoothed bilinearly whatever its /Interpolate, so a zoomed scan has no staircases; smaller images keep their hard pixels. Not measured against Acrobat"
evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it", "crates/render/src/raster/tests.rs::non_interpolated_minification_keeps_thin_strokes", "crates/render/src/raster/tests.rs::a_shrunk_stencil_mask_keeps_thin_strokes", "crates/render/src/raster/tests.rs::minified_image_edges_snap_to_whole_pixels", "crates/render/src/raster/tests.rs::large_non_interpolated_images_are_smoothed_when_enlarged", "crates/render/src/raster/tests.rs::small_non_interpolated_images_keep_hard_pixels_when_enlarged"]
notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value. An image drawn below its resolution is shrunk once to whole device pixels: by area averaging unless it asks for /Interpolate (then Catmull-Rom), so the text of a 300 dpi scan at 100% keeps its strokes, a 1-bit /ImageMask text layer included (it was nearest-neighbour, which dropped them). Enlarged by more than 1%, an image of page-scan size (512 samples or more on its shorter side) is smoothed bilinearly whatever its /Interpolate, so a zoomed scan has no staircases; smaller images keep their hard pixels. Not measured against Acrobat"
[[feature]]
id = "view.render-shadings-patterns"
+1 -1
View File
File diff suppressed because one or more lines are too long
+211 -44
View File
@@ -302,6 +302,12 @@ fn nearest_premultiplied_rgba(
/// icons, QR codes and pixel art are usually far smaller and keep their hard pixels.
const SMOOTHED_IMAGE_SIDE: u32 = 512;
/// PdfCraft patch: the scale above which such an image counts as enlarged. A scan drawn at its own
/// resolution can come out a few ulps above 1 (the scale is an `f32`: a 192 dpi scan on a 2×
/// display at 100%), and sampled bilinearly at a half-pixel offset it blurred by half a pixel.
/// Below 1% nearest-neighbour repeats at most one sample in a hundred.
const SMOOTHED_IMAGE_SCALE: f32 = 1.01;
/// PdfCraft patch: fixed-point unit of [`area_shrink`]'s weights.
const AREA_ONE: u64 = 1 << 16;
@@ -365,60 +371,132 @@ impl AreaAxis {
/// weighted by how much of it it covers. Nothing is invented between samples, which is what
/// `/Interpolate` governs (ISO 32000-2 §8.9.5.3, for images drawn above their resolution), and
/// nothing is dropped: nearest-neighbour keeps one sample in N per axis, so a scan shown at screen
/// size lost the strokes of its text. Premultiplied input stays premultiplied. Work memory is one
/// source row of `u32` accumulators, the horizontal taps (at most `sw + 2 × tw`) and a vertically
/// shrunk copy no larger than the source; a side over `MAX_RESAMPLING_SOURCE_SIDE` is refused, so
/// none of it exceeds a few times one source row. `None` if nothing shrinks, the sizes don't fit
/// or a buffer cannot be allocated.
/// size lost the strokes of its text. Premultiplied input stays premultiplied. `None` if nothing
/// shrinks, the sizes don't fit or a buffer cannot be allocated.
fn area_shrink<const N: usize>(data: &[u8], source: (u32, u32), target: (u32, u32)) -> Option<Vec<u8>> {
if data.len() != source_byte_len(source.0, source.1, N, MAX_IMAGE_PIXELS)? {
return None;
}
let row_len = (source.0 as usize).checked_mul(N)?;
area_shrink_rows::<N>(source, target, |s, row| {
let start = s.checked_mul(row_len)?;
row.copy_from_slice(data.get(start..start.checked_add(row_len)?)?);
Some(())
})
}
/// PdfCraft patch: [`area_shrink`] of a colour image under its same-size soft mask (a stencil's
/// ink under the stencil, or an image under a mask sampled onto its grid), premultiplied a source
/// row at a time as it is averaged. The result equals premultiplying the expanded RGBA and
/// shrinking that, without building it: a 10718 × 13701 stencil shown at page size peaked at
/// 1.16 GB with the full-resolution RGBA, which is 587 MB.
fn area_shrink_premultiplied(
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),
};
if samples.len() != source_byte_len(source.0, source.1, channels, MAX_IMAGE_PIXELS)?
|| alpha.len() != source_byte_len(source.0, source.1, 1, MAX_IMAGE_PIXELS)?
{
return None;
}
let width = source.0 as usize;
area_shrink_rows::<4>(source, target, |s, row| {
let start = s.checked_mul(width)?;
let end = start.checked_add(width)?;
let colors = samples.get(start.checked_mul(channels)?..end.checked_mul(channels)?)?;
let alphas = alpha.get(start..end)?;
for ((out, px), a) in row.chunks_exact_mut(4).zip(colors.chunks_exact(channels)).zip(alphas) {
let [r, g, b] = match px {
[v] => [*v; 3],
[r, g, b] => [*r, *g, *b],
_ => return None,
};
out.copy_from_slice(&AlphaColor::from_rgba8(r, g, b, *a).premultiply().to_rgba8().to_u8_array());
}
Some(())
})
}
/// PdfCraft patch: the area shrink behind [`area_shrink`], reading source row `s` through
/// `read_row(s, row)` into a buffer of one row. Each target row averages the source rows it
/// covers, and that row is shrunk across at once with taps worked out once per image, so work
/// memory is one source row of samples, one of `u32` accumulators, one shrunk row and the
/// horizontal taps (at most `sw + 2 × tw`): never the image. A side over
/// `MAX_RESAMPLING_SOURCE_SIDE` is refused, so none of it exceeds a few times one source row.
fn area_shrink_rows<const N: usize>(
source: (u32, u32),
target: (u32, u32),
mut read_row: impl FnMut(usize, &mut [u8]) -> Option<()>,
) -> Option<Vec<u8>> {
let ((sw, sh), (tw, th)) = (source, target);
// A side over MAX_RESAMPLING_SOURCE_SIDE would make the accumulator row and the tap table
// several times larger than the samples (a 2^27 × 2 image of zeros is a tiny file).
if tw == 0 || th == 0 || tw > sw || th > sh || sw > MAX_RESAMPLING_SOURCE_SIDE || sh > MAX_RESAMPLING_SOURCE_SIDE {
return None;
}
let (sw_us, tw_us) = (sw as usize, tw as usize);
if data.len() != source_byte_len(sw, sh, N, MAX_IMAGE_PIXELS)? {
if tw == 0
|| th == 0
|| tw > sw
|| th > sh
|| (tw, th) == (sw, sh)
|| sw > MAX_RESAMPLING_SOURCE_SIDE
|| sh > MAX_RESAMPLING_SOURCE_SIDE
{
return None;
}
let row_len = (sw as usize).checked_mul(N)?;
let zeroed = |len: usize| {
let mut v = image_buffer(len)?;
v.resize(len, 0);
Some(v)
};
let (mut source_row, mut shrunk_row) = (zeroed(row_len)?, image_buffer(row_len)?);
let mut acc: Vec<u32> = Vec::new();
acc.try_reserve_exact(row_len).ok()?;
acc.resize(row_len, 0);
// The taps depend only on the widths, so they are worked out once, not again for each row.
let taps = if tw == sw { None } else { Some(AreaAxis::new(sw, tw)?) };
let out_len = match taps {
None => source_byte_len(tw, th, N, MAX_IMAGE_PIXELS)?,
Some(_) => image_byte_len(tw, th, N, MAX_IMAGE_PIXELS)?,
};
let mut out = image_buffer(out_len)?;
// Weights sum to AREA_ONE (2^16) and samples are at most 255, so every sum fits in a u32.
let round = |acc: u32| ((acc + (AREA_ONE as u32) / 2) >> 16).min(255) as u8;
// Vertical pass first: groups of whole source rows averaged into each target row. Rows are
// contiguous, so this pass is cheap, and it leaves the horizontal pass, which gathers samples
// per target, a third as many rows when a scan shrinks threefold.
let tall = if th == sh {
None
} else {
let row_len = sw_us * N;
let mut rows = image_buffer(source_byte_len(sw, th, N, MAX_IMAGE_PIXELS)?)?;
let mut acc: Vec<u32> = Vec::new();
acc.try_reserve_exact(row_len).ok()?;
acc.resize(row_len, 0);
for t in 0..th {
// The last source row read: neighbouring target rows share the row on their boundary.
let mut loaded = None;
for t in 0..th {
// Vertical: the whole source rows target row `t` covers, averaged.
let row: &[u8] = if th == sh {
read_row(t as usize, &mut source_row)?;
&source_row
} else {
acc.fill(0);
for (s, w) in area_taps(sh, th, t) {
let row = data.get(s * row_len..(s + 1) * row_len)?;
if loaded != Some(s) {
read_row(s, &mut source_row)?;
loaded = Some(s);
}
// A weight is at most AREA_ONE, so it fits in a u32.
let w = w as u32;
for (a, v) in acc.iter_mut().zip(row) {
for (a, v) in acc.iter_mut().zip(&source_row) {
*a += w * u32::from(*v);
}
}
rows.extend(acc.iter().map(|a| round(*a)));
}
Some(rows)
};
if tw == sw {
return tall;
}
let rows = tall.as_deref().unwrap_or(data);
// Horizontal pass: each of those rows shrunk to `tw` samples. The taps depend only on the
// widths, so they are worked out once, not again for each row.
let taps = AreaAxis::new(sw, tw)?;
let mut out = image_buffer(image_byte_len(tw, th, N, MAX_IMAGE_PIXELS)?)?;
for row in rows.chunks_exact(sw_us * N) {
for t in 0..tw_us {
let (first, weights) = taps.get(t)?;
shrunk_row.clear();
shrunk_row.extend(acc.iter().map(|a| round(*a)));
&shrunk_row
};
// Horizontal: that row shrunk to `tw` samples.
let Some(taps) = &taps else {
out.extend_from_slice(row);
continue;
};
for x in 0..tw as usize {
let (first, weights) = taps.get(x)?;
let samples = row.get(first * N..(first + weights.len()) * N)?;
let mut acc = [0_u32; N];
for (w, px) in weights.iter().zip(samples.chunks_exact(N)) {
@@ -1263,7 +1341,7 @@ impl Renderer {
// nothing, and the stretched side would otherwise be drawn nearest-neighbour.
if !interpolate
&& !self.in_type3_glyph
&& (x_scale > 1.0 || y_scale > 1.0)
&& (x_scale > SMOOTHED_IMAGE_SCALE || y_scale > SMOOTHED_IMAGE_SCALE)
&& source_width.min(source_height) >= SMOOTHED_IMAGE_SIDE
{
quality = ImageQuality::Medium;
@@ -1295,9 +1373,12 @@ impl Renderer {
};
let needs_resize = (new_width, new_height) != (source_width, source_height);
// PdfCraft patch (#624): a minified Type 3 glyph 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.
// PdfCraft patch (#624): a minified image with a soft mask that is neither interpolated nor
// area-averaged (a Type 3 glyph image, or a source wider than MAX_RESAMPLING_SOURCE_SIDE)
// is nearest-sampled straight into premultiplied target RGBA. That skips the
// full-resolution RGBA copy below and the premultiply pass after it. PdfCraft patch: an
// area-averaged one (a stencil's ink, or an image under its mask) skips them too, and is
// premultiplied a source row at a time as it is averaged (`area_shrink_premultiplied`).
let direct = match &alpha_data {
Some(alpha) if needs_resize && !interpolate && !area => nearest_premultiplied_rgba(
&image_data,
@@ -1305,6 +1386,12 @@ impl Renderer {
(source_width, source_height),
(new_width, new_height),
),
Some(alpha) if needs_resize && area => area_shrink_premultiplied(
&image_data,
&alpha.data,
(source_width, source_height),
(new_width, new_height),
),
_ => None,
};
let mut premultiplied = direct.is_some();
@@ -2935,9 +3022,89 @@ mod snap_tests {
#[cfg(test)]
mod area_shrink_tests {
use super::{
AREA_ONE, AreaAxis, LumaData, MAX_IMAGE_PIXELS, area_shrink, area_taps, mask_on_image_grid,
AREA_ONE, AreaAxis, ImageData, LumaData, MAX_IMAGE_PIXELS, RgbData, area_shrink,
area_shrink_premultiplied, area_taps, mask_on_image_grid, premultiply_rgba,
};
/// Deterministic samples that vary from one to the next.
fn noise(len: usize, seed: u32) -> Vec<u8> {
let mut x = seed.wrapping_mul(2_654_435_761) | 1;
(0..len)
.map(|_| {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
(x >> 24) as u8
})
.collect()
}
/// The shrink as two whole passes, every row averaged down and then every row across, each
/// rounded to a byte: what `area_shrink` did before it worked a row at a time.
fn two_pass_shrink<const N: usize>(data: &[u8], (sw, sh): (u32, u32), (tw, th): (u32, u32)) -> Vec<u8> {
let round = |acc: u64| ((acc + AREA_ONE / 2) >> 16).min(255) as u8;
let row_len = sw as usize * N;
let tall: Vec<u8> = (0..th)
.flat_map(|t| {
(0..row_len).map(move |i| {
round(area_taps(sh, th, t).map(|(s, w)| w * u64::from(data[s * row_len + i])).sum())
})
})
.collect();
tall.chunks_exact(row_len)
.flat_map(|row| {
(0..tw).flat_map(move |t| {
(0..N).map(move |c| round(area_taps(sw, tw, t).map(|(s, w)| w * u64::from(row[s * N + c])).sum()))
})
})
.collect()
}
/// Working a row at a time gives the same bytes as two whole passes, in both directions, one
/// direction alone, and with source rows shared by neighbouring targets.
#[test]
fn a_row_at_a_time_shrink_equals_two_whole_passes() {
let sizes = [((7, 5), (3, 2)), ((10, 3), (6, 3)), ((4, 9), (4, 4)), ((13, 11), (1, 1)), ((31, 17), (12, 7))];
for (i, (source, target)) in sizes.into_iter().enumerate() {
let gray = noise((source.0 * source.1) as usize, i as u32);
assert_eq!(area_shrink::<1>(&gray, source, target), Some(two_pass_shrink::<1>(&gray, source, target)), "{source:?} → {target:?}");
let rgba = noise((source.0 * source.1 * 4) as usize, i as u32 + 99);
assert_eq!(area_shrink::<4>(&rgba, source, target), Some(two_pass_shrink::<4>(&rgba, source, target)), "{source:?} → {target:?}");
}
}
/// Premultiplying a row at a time as it is averaged gives the same bytes as premultiplying the
/// whole expanded RGBA first and shrinking that, for gray and RGB colour.
#[test]
fn premultiplying_row_by_row_equals_premultiplying_the_whole_image() {
let sizes = [((7, 5), (3, 2)), ((10, 3), (6, 3)), ((4, 9), (4, 4)), ((31, 17), (12, 7))];
for (i, (source, target)) in sizes.into_iter().enumerate() {
let (w, h) = source;
let pixels = (w * h) as usize;
let alpha = noise(pixels, i as u32 + 7);
let gray = noise(pixels, i as u32 + 11);
let rgb = noise(pixels * 3, i as u32 + 13);
let colors = [
ImageData::Luma(LumaData { data: gray.clone(), width: w, height: h, interpolate: false, scale_factors: (1.0, 1.0) }),
ImageData::Rgb(RgbData { data: rgb.clone(), width: w, height: h, interpolate: false, scale_factors: (1.0, 1.0) }),
];
for color in &colors {
let mut whole: Vec<u8> = match color {
ImageData::Luma(_) => gray.iter().zip(&alpha).flat_map(|(g, a)| [*g, *g, *g, *a]).collect(),
ImageData::Rgb(_) => rgb.chunks_exact(3).zip(&alpha).flat_map(|(c, a)| [c[0], c[1], c[2], *a]).collect(),
};
premultiply_rgba(&mut whole);
assert_eq!(
area_shrink_premultiplied(color, &alpha, source, target),
area_shrink::<4>(&whole, source, target),
"{source:?} → {target:?}"
);
}
let short = alpha.get(1..).unwrap_or_default();
assert_eq!(area_shrink_premultiplied(&colors[0], short, source, target), None, "alpha shorter than the image");
}
}
fn mask(data: &[u8], width: u32, height: u32) -> LumaData {
LumaData { data: data.to_vec(), width, height, interpolate: false, scale_factors: (1.0, 1.0) }
}