Files
pdfcraft/crates/render/tests/it/rotated_image_snap.rs
T
b36bdaeac6 M2: a shrunk image on a turned or mirrored page snaps to whole pixels (#5530) (#5640)
* M2: a shrunk image on a turned or mirrored page snaps to whole pixels (#5530)

hayro patch 14 (`snap_minified_image`) shrinks an interpolated image once
to its whole-pixel device rectangle and draws that 1:1, instead of
shrinking to a rounded-up size and resampling again at a fractional
scale and offset. It only accepted an upright transform (a > 0, d > 0,
b = c = 0), so every image on a page under /Rotate 90, 180 or 270 -- and
any image placed turned or mirrored by its own matrix -- still took the
double resample. That is #5530: a scanned page that matches PDFium
upright drifts and softens when the same page is rotated.

The snap now accepts every transform whose axes lie along the device's
(the eight quarter-turns and mirrors) and returns the 1:1 transform
that draws the shrunk raster from the snapped corner, one source-axis
step a whole device pixel, in whichever direction that axis runs.
Skewed and freely rotated images keep the old path.

On a synthetic page (crop box and scale on whole pixels, image at
fractional coordinates), /Rotate 90, 180 and 270 renders turned back
differed from the upright render in 72% of pixels, by up to 200 grey
levels; now they are identical. Non-interpolated images were already
identical and stay so.

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

* M2: snap only when the dropped skew is invisible, and only a resize that succeeded

Review of #5640:

- The snap judged an image axis-aligned by a tolerance on its
  coefficients (1e-4 of the scale). For a very wide image a skew that
  small still moves the far edge by more than a pixel (40 000 samples
  at 4e-5: 1.6 px), and the snap flattened it. It now measures what it
  drops: how far each off-axis term moves the far edges, at most a
  hundredth of a device pixel (SNAP_SKEW). Upright and turned alike.
- A resize that fails hands back the source raster at source size; the
  1:1 snapped transform then drew it at that size. The snapped
  transform is now used only when the shrink produced the snapped
  size; otherwise the full transform, as before.
- The mirrored case of the edge test drew a uniform image, which looks
  the same mirrored; it now also mirrors a half-black image and checks
  which side the black lands on.

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

* M2: the turned-page test draws every kind of image the snap reaches

The /Rotate oracle test drew one 8-bit grey image. Stencil masks (CCITT
scans often are one), 1-bit images and images with a soft mask reach
the snap through other paths, so it now draws all five: 8-bit and
1-bit grey, a stencil mask, a soft mask of the image's size and one of
another size. On main, every interpolated kind is off by 113-217 levels
over 63-73% of the image when turned, and so is a non-interpolated
image with a smaller soft mask (110 levels, 16%): hayro patch 15
filters such a mask whatever /Interpolate says, so it is snapped
upright and was not when turned. All are identical with the fix; the
other non-interpolated kinds were already identical and stay as guards.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-01 15:07:29 -05:00

164 lines
7.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Contributor-original synthetic pages (#5530): a shrunk image on a page turned by `/Rotate` is
//! drawn exactly as on the upright page, turned. The crop box and scale put every page edge on a
//! whole device pixel, so a turn is a pure permutation of pixels and the turned render, turned
//! back, must match the upright one. The image itself sits at fractional coordinates: an upright
//! image there is snapped to whole pixels and shrunk once, and before the fix a turned one was
//! not, so it was shrunk to a rounded-up size and resampled a second time at a fractional scale
//! and offset (most of its pixels off, by up to 217 levels).
//!
//! Every kind of image that reaches the snap is drawn: an 8-bit and a 1-bit grey image, a stencil
//! mask (a CCITT scan is often one), and images with a soft mask of their own size and of another
//! size. The last needs no `/Interpolate`: a shrunk soft mask of another size is filtered, and so
//! snapped, whatever the image asks (hayro patch 15). The other kinds without `/Interpolate` are
//! not snapped upright or turned, and were already the same both ways; they stay in the test as a
//! guard.
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RenderedPage};
use std::sync::Arc;
/// A PDF of the given objects (object 1 the catalog), with a cross-reference table.
fn pdf(objects: &[Vec<u8>]) -> Vec<u8> {
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
bytes.extend_from_slice(object);
bytes.extend_from_slice(b"\nendobj\n");
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
bytes
}
fn stream(dict: &str, data: &[u8]) -> Vec<u8> {
let mut bytes = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes();
bytes.extend_from_slice(data);
bytes.extend_from_slice(b"\nendstream");
bytes
}
/// Image size in samples.
const W: usize = 400;
const H: usize = 500;
/// A busy grey pattern with edges both ways, as a scan has.
fn grey_samples() -> Vec<u8> {
(0..H).flat_map(|y| (0..W).map(move |x| ((x * 7 + y * 13) % 256) as u8 ^ if (x / 3 + y / 5) % 2 == 0 { 0 } else { 0x80 })).collect()
}
/// One bit a sample, rows padded to whole bytes: a text-like pattern of thin strokes.
fn bit_samples() -> Vec<u8> {
let row = W.div_ceil(8);
let mut out = vec![0u8; row * H];
for y in 0..H {
for x in 0..W {
if (x / 2 + y / 3) % 3 == 0 || (x * 5 + y * 3) % 11 < 3 {
out[y * row + x / 8] |= 0x80 >> (x % 8);
}
}
}
out
}
fn alpha_samples(w: usize, h: usize) -> Vec<u8> {
(0..h).flat_map(|y| (0..w).map(move |x| ((x * 3 + y * 5) % 256) as u8)).collect()
}
/// The kinds of image drawn.
const KINDS: [&str; 5] = ["8-bit grey", "1-bit grey", "stencil mask", "soft mask", "smaller soft mask"];
/// The image of `kind` as objects 5 (and 6, its soft mask).
fn image(kind: &str, interpolate: bool) -> Vec<Vec<u8>> {
let image = |depth: u8, extra: &str, data: &[u8]| {
let dict = format!(
"/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent {depth} /Interpolate {interpolate}{extra}"
);
stream(&dict, data)
};
let mask = |w: usize, h: usize| {
let dict =
format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate {interpolate}");
stream(&dict, &alpha_samples(w, h))
};
match kind {
"8-bit grey" => vec![image(8, "", &grey_samples())],
"1-bit grey" => vec![image(1, "", &bit_samples())],
"stencil mask" => vec![stream(
&format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ImageMask true /BitsPerComponent 1 /Interpolate {interpolate}"),
&bit_samples(),
)],
"soft mask" => vec![image(8, " /SMask 6 0 R", &grey_samples()), mask(W, H)],
_ => vec![image(8, " /SMask 6 0 R", &grey_samples()), mask(300, 350)],
}
}
/// A page whose crop box sits inside a larger media box, as in the report, with the image drawn
/// at fractional coordinates inside it, in a colour (a stencil mask paints it).
fn page(rotate: u32, kind: &str, interpolate: bool) -> Vec<u8> {
let mut objects = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 794 1224] /CropBox [114 510 680 1210] /Rotate {rotate} /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> >>"
)
.into_bytes(),
stream("", b"0.1 0.2 0.6 rg q 500.3 0 0 600.7 150.15 560.45 cm /Im Do Q"),
];
objects.extend(image(kind, interpolate));
pdf(&objects)
}
/// At half a pixel per point every crop-box edge, upright or turned, is a whole device pixel,
/// and the 400 × 500 image is shrunk to about 250 × 300 pixels.
fn render(rotate: u32, kind: &str, interpolate: bool) -> RenderedPage {
let mut renderer = PageRenderer::new(Arc::new(page(rotate, kind, interpolate)), RenderConfig::default());
let out = renderer.render(RenderRequest { page: 0, scale: 0.5, ..Default::default() });
assert!(out.error.is_none(), "{kind}: {:?}", out.error);
out
}
fn rgb(out: &RenderedPage, x: u32, y: u32) -> [u8; 3] {
let i = ((y * out.width + x) * 4) as usize;
[out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]]
}
/// The largest channel difference between the turned render and the upright one turned the same
/// way.
fn turned_difference(rotate: u32, kind: &str, interpolate: bool) -> u8 {
let upright = render(0, kind, interpolate);
let turned = render(rotate, kind, interpolate);
let (w, h) = (upright.width, upright.height);
// `/Rotate` turns the page clockwise.
let expected = |x: u32, y: u32| match rotate {
90 => rgb(&upright, y, h - 1 - x),
180 => rgb(&upright, w - 1 - x, h - 1 - y),
_ => rgb(&upright, w - 1 - y, x),
};
let (tw, th) = if rotate == 180 { (w, h) } else { (h, w) };
assert_eq!((turned.width, turned.height), (tw, th), "{kind}, /Rotate {rotate}");
let mut worst = 0;
for y in 0..th {
for x in 0..tw {
let (got, want) = (rgb(&turned, x, y), expected(x, y));
worst = (0..3).map(|c| got[c].abs_diff(want[c])).fold(worst, u8::max);
}
}
worst
}
#[test]
fn a_shrunk_image_on_a_turned_page_is_the_upright_render_turned() {
for kind in KINDS {
for interpolate in [true, false] {
for rotate in [90, 180, 270] {
let worst = turned_difference(rotate, kind, interpolate);
assert!(worst <= 1, "{kind}, /Rotate {rotate}, /Interpolate {interpolate}: off by up to {worst} levels");
}
}
}
}