diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 875528a87..1480ca219 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -951,11 +951,24 @@ impl DocView { } fn render_scale(&self, ppp: f32) -> f32 { - // Quantize so tiny fit-width changes don't trigger re-renders. - ((self.zoom * PT * ppp) * 64.0).round() / 64.0 + // Exactly the device scale: a raster at any other scale is resampled on screen, which + // blurs every line and glyph (#260). + self.zoom * PT * ppp } } +/// The request tag for a raster at `scale`: equal tags mean the same scale (to 1/65536). +fn scale_tag(scale: f32) -> u64 { + (f64::from(scale) * 65536.0).round() as u64 +} + +/// `r` moved so its corner lies on a whole physical pixel, so that a raster drawn from there +/// maps texel for texel onto the screen. +fn snap_to_pixels(r: Rect, ppp: f32) -> Rect { + let snap = |v: f32| (v * ppp).round() / ppp; + Rect::from_min_size(pos2(snap(r.min.x), snap(r.min.y)), r.size()) +} + /// Maps between a page's coordinate spaces and the screen, including view rotation. /// /// *View space* is the page as rendered (points, y down, the document's own `/Rotate` applied); @@ -1041,6 +1054,15 @@ impl PageXform { Rect::from_two_pos(self.norm_to_screen(a.0, a.1), self.norm_to_screen(b.0, b.1)) } + /// This transform resized to a raster of `px` device pixels (width, height before the view + /// rotation), so each of its texels covers one screen pixel. The rectangle's corner must + /// already be on a whole pixel ([`snap_to_pixels`]). + fn texel_aligned(&self, px: [usize; 2], ppp: f32) -> PageXform { + let (w, h) = (px[0] as f32 / ppp, px[1] as f32 / ppp); + let size = if self.rot % 180 == 90 { vec2(h, w) } else { vec2(w, h) }; + PageXform { rect: Rect::from_min_size(self.rect.min, size), ..*self } + } + /// Draw a texture covering the normalised page region, rotated with the view. pub fn paint_image(&self, painter: &egui::Painter, tex: egui::TextureId, u0: f32, v0: f32, u1: f32, v1: f32) { let mut mesh = egui::Mesh::with_texture(tex); @@ -1278,7 +1300,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } let ppp = ui.ctx().pixels_per_point(); let scale = view.render_scale(ppp); - let tag = (scale * 1000.0) as u64; + let tag = scale_tag(scale); let hand = app.quick_tool == QuickTool::Hand; let tool = app.quick_tool; // Text selection runs for the Select tool and for the markup tools (highlight…). @@ -1380,7 +1402,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let mut current_overlap = -1.0f32; let pointer = ui.input(|i| i.pointer.hover_pos()); for &i in &visible_pages { - let r = rects[i].translate(origin.to_vec2()); + let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp); if !r.intersects(visible.expand(400.0)) { continue; } @@ -1423,13 +1445,18 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Whole-page raster: sharp when small, a low-res backdrop when tiled. let (want_scale, want_tag) = if tiled { let bs = BASE_SIDE / pw_pt.max(ph_pt); - (bs, (bs * 1000.0) as u64) + (bs, scale_tag(bs)) } else { (scale, tag) }; match view.pages.get(&i) { Some(p) => { - xf.paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0); + if !tiled && p.tag == want_tag { + xf.texel_aligned(p.tex.size(), ppp).paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0); + } else { + // A backdrop, or a raster at an older scale until the new one arrives. + xf.paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0); + } if p.tag != want_tag { wanted.push((i, want_scale, want_tag, None)); } @@ -1463,7 +1490,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { match view.tiles.get(&(i, tx, ty)) { Some((ttag, tex)) if *ttag == tag => { let (fw, fh) = (dw as f32, dh as f32); - xf.paint_image(painter, tex.id(), x as f32 / fw, y as f32 / fh, (x + w) as f32 / fw, (y + h) as f32 / fh); + xf.texel_aligned([dw as usize, dh as usize], ppp).paint_image( + painter, + tex.id(), + x as f32 / fw, + y as f32 / fh, + (x + w) as f32 / fw, + (y + h) as f32 / fh, + ); } _ => wanted.push((i, scale, tag, Some(Tile { x, y, w, h }))), } diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index b51ba6bd0..52ff900ca 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -567,3 +567,78 @@ fn required_fields_get_a_red_border_when_highlighting() { let px = *img.get_pixel((at.x * ppp) as u32, (at.y * ppp) as u32); assert!(px[0] > 180 && px[1] < 100 && px[2] < 100, "a red border: {px:?}"); } + +/// The page raster is shown texel for texel. At a zoom whose device scale isn't on the old 1/64 +/// grid (87 % at 1 px per point: 1.16, rendered at 1.15625) the page was rendered at the +/// quantized scale and stretched onto a rectangle that didn't start on a whole pixel, so every +/// line and glyph was resampled and looked soft (#260, page 9). +#[test] +fn page_raster_maps_one_to_one_onto_screen_pixels() { + let _gpu = gpu(); + // Fine vertical and horizontal lines (0.5 pt every 1.7 pt) fill most of a 300×400 pt page. + let mut content = String::from("0 g\n"); + for i in 0..140 { + let v = 30.0 + 1.7 * i as f32; + content.push_str(&format!("{v} 40 0.5 320 re 30 {v} 240 0.5 re\n")); + } + content.push_str("f\n"); + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] /Contents 4 0 R >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ) + .into_bytes(); + for ppp in [1.0, 2.0] { + let bytes = pdf.clone(); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).with_pixels_per_point(ppp).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.open_bytes("lines.pdf", None, bytes.clone()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app.set_option("zoom", "87").unwrap(); + app + }); + let (mean, worst) = compare_page_with_raster(&mut h, &pdf, 0.87); + assert!(mean < 1.0 && worst < 16, "{ppp} px/pt: the page on screen differs from its raster: mean {mean:.2}, max {worst}"); + } +} + +/// Mean and largest difference between page 1 on screen and the page rendered directly at the +/// view's exact device scale, over the lined area of `page_raster_maps_one_to_one_onto_screen_pixels`. +fn compare_page_with_raster(h: &mut Harness<'static, PdfCraftApp>, pdf: &[u8], zoom: f32) -> (f64, u8) { + for _ in 0..100 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let ppp = h.ctx.pixels_per_point(); + let r = rect(h, 0).expect("page 1"); + let img = h.render().expect("renders"); + let scale = zoom * 96.0 / 72.0 * ppp; + let mut renderer = pdfcraft_render::PageRenderer::new(std::sync::Arc::new(pdf.to_vec()), pdfcraft_render::RenderConfig::default()); + let page = renderer.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, tile: None, scale, tag: 0 }); + assert!(page.error.is_none(), "{:?}", page.error); + // Compare the lined area (page x 40..270 pt, y 60..330 pt from the top), screen against raster. + let (ox, oy) = ((r.min.x * ppp).round() as i64, (r.min.y * ppp).round() as i64); + let (mut sum, mut worst, mut n) = (0u64, 0u8, 0u64); + for py in (60.0 * scale) as u32..(330.0 * scale) as u32 { + for px in (40.0 * scale) as u32..(270.0 * scale) as u32 { + let want = page.rgba[((py * page.width + px) * 4) as usize]; + let got = img.get_pixel((ox + px as i64) as u32, (oy + py as i64) as u32)[0]; + let d = want.abs_diff(got); + sum += u64::from(d); + worst = worst.max(d); + n += 1; + } + } + (sum as f64 / n as f64, worst) +}