mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-03 15:05:02 -06:00
* 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>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
07582e9ed4
commit
b36bdaeac6
@@ -6276,6 +6276,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];
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+76
-22
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user