M2: snap shrunk interpolated images to whole pixels so scans stay sharp (#1377)

* M2: snap shrunk interpolated images to whole pixels so scans stay sharp

A scanned page (one 300 dpi image with /Interpolate true) was shrunk to a
ceil-sized raster and then sampled a second time at a fractional scale and
offset, which softened every edge next to Acrobat. An axis-aligned shrunk
interpolated image is now shrunk once to its rounded device rectangle and
drawn 1:1 at the rounded corner. Rotated, skewed, flipped, Type 3 and
non-interpolated images are unchanged.

On a synthetic scan page the mean error against an area-average reference
falls from 3.4 to 1.6 grey levels at 96 dpi (3.0 to 1.5 at 144 dpi).

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

* vendor: list the hayro snap patch as (14) in the Patches and Tests columns; correct gaps.md patch counts

The patch description had been appended to the Tests column of one of the
two hayro rows. gaps.md now counts the distinct patches vendor/README.md
lists (26 hayro-interpret, 15 hayro, 3 hayro-jbig2, 17 hayro-syntax).

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
philip-tam 2026-10-12 12:12:02 +01:00 committed by GitHub
parent 186c1dc767
commit 88d96c93a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 85 additions and 9 deletions

View File

@ -143,6 +143,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-12 (M7, Arabic ActualText):** Add text records each shaped line in typing order with `/ActualText`, so copy and search preserve mixed Arabic, Latin, digits and brackets. Edit text keeps the logical order supplied by `/ActualText` instead of reversing Arabic or Hebrew a second time. Adjacent visual glyph runs retain their own bidi recovery. Font-independent tests cover Arabic marks, numbers, Latin words, reopen and complete replacement; automation checks saved editor text and unchanged rendering. Font fallback and bidi typing remain separate gaps; estimates unchanged.
- **2026-10-12 (M5, Fill & Sign text size):** The active Fill & Sign toolbar now chooses 4–300 pt for new text and dates; the in-place editor and saved annotation use that size, which persists in local settings. `fill_sign_add` accepts the same `font_size`. Feature and readiness estimates unchanged.
- **2026-10-12 (community, M3, Greek search):** Case-insensitive Find treats normal, final and capital sigma (`σ`, `ς`, `Σ`) as the same letter, so `κόσμος` and `ΚΌΣΜΟΣ` find each other. Case-sensitive and whole-word options, glyph ranges and match limits retain their behavior. Synthetic text-search regressions cover both directions. The tab-menu regression selects the platform's Reveal label (Finder on macOS), restoring the macOS test without changing the menu. Estimates unchanged.
- **2026-10-12 (M2, soft scans):** A shrunk, interpolated, axis-aligned image (a 300 dpi scan on a screen) is snapped to whole device pixels and shrunk once, not resampled a second time at a fractional scale and offset. On a synthetic scan page the error against an area-average reference fell from 3.4 to 1.6 grey levels at 96 dpi, and edge sharpness is within 2% of the reference (was 10% soft). Rotated, skewed, flipped and non-interpolated images are unchanged; downscaling of those is still open.
- **2026-10-12 (M12, XFA scripts turned off):** When a runaway script turns an XFA form's scripts off, the app now says so: a toast as it happens (at open or after a click), and the notice bar from then on, ahead of its other notices, with a JavaScript Console button. Before, the error was only in the console, so calculations and buttons just stopped. A notice bar the user had closed comes back for it; closed again, it stays closed. The engine reports the state (`Document::xfa_scripts_off`, and `xfa_scripts_off` in `doc_info`) instead of the UI reading error text. Two kittest UI tests (a runaway at open; a runaway click after the bar was closed), engine assertions and a `doc_info` tool test. Overall unchanged.
- **2026-10-12 (M14, Windows low-DPI font-hinting candidate):** The Windows policy disables symmetric rendering for embedded JetBrains Mono only. On the locked-font macOS probe, horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150% scaling, and is down 0.79% at 200%; Inter is pixel-identical. Logical layout hashes and 8 MiB atlas size match, repeated fractional offsets add no atlas dimensions, and baseline/candidate time and RSS ranges overlap. Windows runtime and hardware remain unverified; estimates unchanged.
- **2026-10-12 (M7, #1200 candidate):** Bounded local GoToR file specifications open in desktop tabs; explicit numeric page views apply, while named destinations open at the default view with a warning. The automation tool resolves relative to the source and uses existing `--root` confinement. Appearance-only Link Properties edits preserve the existing action. URL/network paths remain refused; `/NewWindow` is a tab hint. Estimates unchanged.

View File

@ -4741,6 +4741,41 @@ trailer << /Root 1 0 R >>
assert!(pixels(&enabled).iter().any(|px| px[..3].iter().any(|&c| (32..=223).contains(&c))), "/Interpolate true made no mid-tone");
}
/// A shrunk, interpolated image whose edges fall between device pixels is snapped to whole
/// pixels (shrunk once to that size, drawn 1:1). Before, the shrunk raster was sampled a second
/// time at the fractional offset, so every edge of a scan was a blended, soft pixel.
#[test]
fn interpolated_minified_image_edges_snap_to_whole_pixels() {
let black = vec![0u8; 40 * 40];
let content = "q 10 0 0 10 0.4 0.4 cm /Im0 Do Q";
let image = hex(&black);
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(), "{:?}", 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.4..10.4 × 0.6..10.6 snaps to columns 0..10 and rows 1..11.
for y in 1..11 {
for x in 0..10 {
assert_eq!(gray(x, y), 0, "pixel ({x}, {y}) inside the snapped image");
}
assert_eq!(gray(10, y), 255, "column 10 is outside the snapped image (row {y})");
}
for x in 0..11 {
assert_eq!(gray(x, 0), 255, "row 0 is outside the snapped image (column {x})");
}
}
#[test]
fn non_interpolated_gray_images_are_not_smoothed_when_minified() {
let levels = [0u8, 255, 85, 170, 40, 200, 120, 230];

View File

@ -53,8 +53,8 @@ update the numbers.
- **Missing:** our own renderer (M2: `model` crate, font engine, DisplayList devices, ADR-0004), or
an owner decision to adopt another engine; and any measurement of rendering against Acrobat.
- **Evidence:** pages are drawn by vendored `hayro` 0.7 with 23 local patches to `hayro-interpret`,
10 to `hayro`, 3 to `hayro-jbig2` and 13 to `hayro-syntax` (`vendor/README.md`); `lopdf` still
- **Evidence:** pages are drawn by vendored `hayro` 0.7 with 26 local patches to `hayro-interpret`,
15 to `hayro`, 3 to `hayro-jbig2` and 17 to `hayro-syntax` (`vendor/README.md`); `lopdf` still
backs document inspection. All 7 rendering P0 features are `partial`. User reports: TikZ pattern
fills drawn wrong ([#794](https://github.com/storytold/pdfcraft/issues/794)), blurry text at
1080p/1440p ([#730](https://github.com/storytold/pdfcraft/issues/730)), fixed zoom sizes differ

4
vendor/README.md vendored

File diff suppressed because one or more lines are too long

View File

@ -401,6 +401,32 @@ 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).
fn snap_minified_image(
transform: &Affine,
source: (u32, u32),
planned: (u32, u32),
) -> Option<(u32, u32, f64, f64)> {
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;
}
let edge = |origin: f64, scale: f64, side: u32| {
let (lo, hi) = (origin.round(), (origin + scale * f64::from(side)).round());
(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))
}
pub(crate) struct Renderer {
pub(crate) ctx: RenderContext,
/// PdfCraft patch: when set, solid text and line-art paint colours are remapped
@ -784,6 +810,20 @@ 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.
let snapped = if interpolate
&& requested_resize
&& !self.in_type3_glyph
&& (new_width, new_height) != (source_width, source_height)
{
snap_minified_image(&cur_transform, (source_width, source_height), (new_width, new_height))
} else {
None
};
let (new_width, new_height) = snapped.map_or((new_width, new_height), |s| (s.0, s.1));
let needs_resize = (new_width, new_height) != (source_width, source_height);
// PdfCraft patch (#624): a minified image with a soft mask that is not interpolated is
@ -932,11 +972,11 @@ impl Renderer {
img_height as u16,
may_have_transparency,
);
self.draw_pixmap(
Arc::new(pixmap),
quality,
cur_transform * additional_transform,
);
let draw_transform = match snapped {
Some((_, _, x0, y0)) => Affine::translate((x0, y0)),
None => cur_transform * additional_transform,
};
self.draw_pixmap(Arc::new(pixmap), quality, draw_transform);
}
fn push_clip_path_inner(&mut self, clip_path: &BezPath, fill: FillRule) {