Merge origin/main into feat/jbig2-run-allowance

This commit is contained in:
FredApps
2026-11-01 21:10:07 +01:00
5 changed files with 313 additions and 24 deletions
+71
View File
@@ -6490,6 +6490,77 @@ fn interpolated_minified_image_edges_snap_to_whole_pixels() {
}
}
/// #5530: an image placed turned or mirrored (by its own matrix or the page's `/Rotate`) snaps to
/// whole pixels just as an upright one does. Before, only upright images were snapped, so a turned
/// scan was resampled twice and every edge was a blended, soft pixel. This checks the edges, and
/// which way a mirrored image faces; `rotated_image_snap` in the integration tests checks which
/// way a turned page's image faces (`/Rotate` only turns, it never mirrors).
#[test]
fn a_turned_or_mirrored_minified_image_snaps_to_whole_pixels() {
let black = vec![0u8; 40 * 40];
let image = hex(&black);
// Each matrix maps the image onto the user box 0.6..10.6 × 0.4..10.4, a different way round.
for matrix in [
"0 10 -10 0 10.6 0.4", // a quarter turn
"-10 0 0 -10 10.6 10.4", // a half turn
"0 -10 10 0 0.6 10.4", // three quarters
"-10 0 0 10 10.6 0.4", // mirrored left to right
] {
let content = format!("q {matrix} 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 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(), "{matrix}: {:?}", 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.6..10.6 × 0.6..10.6 snaps to columns 1..11 and rows 1..11: no soft edge.
for y in 0..11 {
for x in 0..11 {
let inside = (1..11).contains(&x) && (1..11).contains(&y);
assert_eq!(gray(x, y), if inside { 0 } else { 255 }, "{matrix}: pixel ({x}, {y})");
}
}
}
// A uniform image looks the same mirrored, so mirror one whose left half (source columns
// 0..20) is black: drawn left to right reversed, the black half is on the right.
let halves: Vec<u8> = (0..40).flat_map(|_| (0..40).map(|x| if x < 20 { 0 } else { 255 })).collect();
let halves = hex(&halves);
let content = "q -10 0 0 10 10.6 0.4 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 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{halves}>\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF\n",
content.len(),
halves.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 gray = |x: usize, y: usize| page.rgba[(y * 11 + x) * 4];
// The snapped box is columns 1..11; columns 1..3 and 8..11 are well clear of the change at 6.
for y in 2..10 {
for x in 1..4 {
assert_eq!(gray(x, y), 255, "mirrored: the left of the box shows the image's right (white) half, pixel ({x}, {y})");
}
for x in 8..11 {
assert_eq!(gray(x, y), 0, "mirrored: the right of the box shows the image's left (black) half, pixel ({x}, {y})");
}
}
}
#[test]
fn non_interpolated_gray_images_are_not_smoothed_when_minified() {
let levels = [0u8, 255, 85, 170, 40, 200, 120, 230];
+1
View File
@@ -8,6 +8,7 @@ mod luminosity_group_space;
mod named_form_properties;
mod nonseparable_gray;
mod open_type_cff_simple_font;
mod rotated_image_snap;
mod search;
mod separation_all;
mod stray_q_in_a_form;
@@ -0,0 +1,163 @@
//! 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");
}
}
}
}
+2 -2
View File
File diff suppressed because one or more lines are too long
+76 -22
View File
@@ -401,30 +401,55 @@ fn may_paint_rect(rect: Rect, transform: Affine, width: u16, height: u16) -> boo
paints_canvas(device, width, height)
}
/// PdfCraft patch: the whole-pixel device rectangle of a shrunk, axis-aligned image, as
/// `(width, height, x0, y0)`. `transform` maps source pixels to device pixels and `planned` is the
/// size [`image_resampling_size`] chose; `None` (draw as before) for rotated, skewed or flipped
/// images, non-finite geometry, or a snapped size that strays more than a pixel from `planned`
/// (so the memory budget that size passed still holds).
/// PdfCraft patch: how far, in device pixels, the terms [`snap_minified_image`] drops may move an
/// image's far edges: a hundredth of a pixel, which no edge shows. Rounding noise in a turned or
/// scaled matrix stays far below it.
const SNAP_SKEW: f64 = 0.01;
/// PdfCraft patch: the whole-pixel device rectangle of a shrunk image whose axes lie along the
/// device's, as `(width, height, transform)`: the size to shrink it to (along its own axes) and
/// the transform that draws that raster 1:1, one source-axis step a whole device pixel. Upright,
/// mirrored and quarter-turned images all qualify, so a page under `/Rotate` (or an image placed
/// turned) is drawn as sharply as upright (#5530). `transform` maps source pixels to device
/// pixels and `planned` is the size [`image_resampling_size`] chose; `None` (draw as before) for
/// skewed or freely rotated images, non-finite geometry, or a snapped size that strays more than
/// a pixel from `planned` (so the memory budget that size passed still holds). An image counts
/// as axis-aligned when the terms the snap drops move its far edges by at most [`SNAP_SKEW`]: a
/// tolerance on the coefficients alone let a slight skew of a very wide image through, though it
/// moved the far edge by more than a pixel.
fn snap_minified_image(
transform: &Affine,
source: (u32, u32),
planned: (u32, u32),
) -> Option<(u32, u32, f64, f64)> {
) -> Option<(u32, u32, Affine)> {
let [a, b, c, d, e, f] = transform.as_coeffs();
let tiny = 1e-4 * a.abs().max(d.abs());
if !(a > 0.0 && d > 0.0 && b.abs() <= tiny && c.abs() <= tiny) {
return None;
}
// The device span of a source edge from `origin` across `side` samples of `scale`, rounded.
let edge = |origin: f64, scale: f64, side: u32| {
let (lo, hi) = (origin.round(), (origin + scale * f64::from(side)).round());
let (p, q) = (origin.round(), (origin + scale * f64::from(side)).round());
let (lo, hi) = (p.min(q), p.max(q));
(hi - lo >= 1.0 && lo.abs() < 1e6 && hi.abs() < 1e6).then_some((lo, hi))
};
let (x0, x1) = edge(e, a, source.0)?;
let (y0, y1) = edge(f, d, source.1)?;
// Bounded by the 1e6 check above, so the casts cannot saturate.
let (w, h) = ((x1 - x0) as u32, (y1 - y0) as u32);
(w.abs_diff(planned.0) <= 1 && h.abs_diff(planned.1) <= 1).then_some((w, h, x0, y0))
// Along an axis drawn backwards (negative scale), the raster starts at the span's far end.
let start = |scale: f64, (lo, hi): (f64, f64)| if scale > 0.0 { lo } else { hi };
let step = |scale: f64| if scale > 0.0 { 1.0 } else { -1.0 };
// How far a dropped term moves the image's far edge: `scale` across `side` source samples.
let negligible = |scale: f64, side: u32| (scale * f64::from(side)).abs() <= SNAP_SKEW;
let (width, height) = source;
// Source x along device x, source y along device y (upright or mirrored), or a quarter turn:
// source x along device y, source y along device x.
let (w, h, draw) = if negligible(b, width) && negligible(c, height) {
let (x, y) = (edge(e, a, source.0)?, edge(f, d, source.1)?);
// Bounded by the 1e6 check above, so the casts cannot saturate.
let (w, h) = ((x.1 - x.0) as u32, (y.1 - y.0) as u32);
(w, h, Affine::new([step(a), 0.0, 0.0, step(d), start(a, x), start(d, y)]))
} else if negligible(a, width) && negligible(d, height) {
let (x, y) = (edge(e, c, source.1)?, edge(f, b, source.0)?);
let (w, h) = ((y.1 - y.0) as u32, (x.1 - x.0) as u32);
(w, h, Affine::new([0.0, step(b), step(c), 0.0, start(c, x), start(b, y)]))
} else {
return None;
};
(w.abs_diff(planned.0) <= 1 && h.abs_diff(planned.1) <= 1).then_some((w, h, draw))
}
/// PdfCraft patch (19): what `Renderer::set_paint` found out: the clip path to apply while
@@ -960,10 +985,11 @@ impl Renderer {
if requested_resize && self.in_type3_glyph && interpolate {
quality = ImageQuality::High;
}
// PdfCraft patch: an axis-aligned, interpolated image that is shrunk lands on whole device
// pixels. It is shrunk once to exactly its snapped device rectangle and drawn 1:1 at the
// snapped corner. Otherwise the shrunk raster (rounded up to a whole size) is sampled a
// second time at a fractional scale and offset, which blurs scans and photos.
// PdfCraft patch: an interpolated image that is shrunk, with its axes along the device's
// (upright, mirrored or a quarter turn), lands on whole device pixels. It is shrunk once to
// exactly its snapped device rectangle and drawn 1:1 from the snapped corner. Otherwise the
// shrunk raster (rounded up to a whole size) is sampled a second time at a fractional scale
// and offset, which blurs scans and photos.
let snapped = if interpolate
&& requested_resize
&& !self.in_type3_glyph
@@ -1122,9 +1148,12 @@ impl Renderer {
img_height as u16,
may_have_transparency,
);
// The snapped transform draws the raster 1:1, so it holds only if the shrink produced the
// snapped size. A failed resize hands back the source raster: drawn 1:1 it would come out
// at its source size, so it keeps the full transform instead.
let draw_transform = match snapped {
Some((_, _, x0, y0)) => Affine::translate((x0, y0)),
None => cur_transform * additional_transform,
Some((w, h, draw)) if (img_width, img_height) == (w, h) => draw,
_ => cur_transform * additional_transform,
};
self.draw_pixmap(Arc::new(pixmap), quality, draw_transform);
}
@@ -2581,3 +2610,28 @@ pub fn tiling_cell_pixels(scale: f32, step: f32, max_pixels: f32) -> (u16, f64)
// (`exact` is), so the division is too.
(pixels as u16, pixels / step)
}
#[cfg(test)]
mod snap_tests {
use super::{snap_minified_image, Affine};
/// The skew a snap drops is measured as the distance it moves the image's far edge, not as a
/// coefficient against the scale: a 40 000 sample wide image skewed by 4e-5 per sample moves
/// its far edge 1.6 device pixels, and must keep its full transform (#5640 review).
#[test]
fn a_skew_that_moves_a_far_edge_is_not_snapped() {
let source = (40_000, 400);
let planned = (20_000, 200);
// Upright, shrunk to half, sheared so the far end sits 1.6 pixels lower: not snapped.
let skewed = Affine::new([0.5, 4e-5, 0.0, 0.5, 0.3, 0.3]);
assert!(snap_minified_image(&skewed, source, planned).is_none());
// A quarter turn with the same stray term along device x: not snapped either.
let turned = Affine::new([4e-5, 0.5, -0.5, 0.0, 200.3, 0.3]);
assert!(snap_minified_image(&turned, source, (20_000, 200)).is_none());
// Rounding noise far below a hundredth of a pixel at the far edge still snaps.
let noisy = Affine::new([0.5, 1e-9, 0.0, 0.5, 0.3, 0.3]);
assert!(snap_minified_image(&noisy, source, planned).is_some());
let turned_noisy = Affine::new([1e-9, 0.5, -0.5, 0.0, 200.3, 0.3]);
assert!(snap_minified_image(&turned_noisy, source, (20_000, 200)).is_some());
}
}