mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
View: page rasters map texel for texel onto the screen (sharp text at any zoom) (#363)
#260 (page 9) reports text that looks softer than in Acrobat once zoomed. The page was rendered at the zoom's device scale rounded to 1/64 (render_scale, to spare re-renders on tiny fit-width changes), and the texture was stretched onto the page's layout rectangle, whose corner usually isn't on a whole physical pixel. Every line and glyph went through linear resampling: at 87 % on a 2x display the raster was 0.32 % too small for its rectangle, and #260's page 9 lost 7.9 % sharpness against a direct render (1.7 % at 137 %; 0 at 150 %, which happens to quantize exactly). The page now renders at the exact device scale (tags resolve 1/65536 of a scale step instead of 1/1000), each page rectangle starts on a whole physical pixel, and a raster at the current scale is drawn at its own pixel size from there, whole page or tile. A raster from an older scale (mid-zoom) and the low-resolution backdrop under tiles still stretch, as before. With the fix, page 9 on screen matches a direct render exactly (mean difference 0.00/255 at 87 % and 137 %, was 6.30 and 2.66). Test: view.rs::page_raster_maps_one_to_one_onto_screen_pixels renders a page of 0.5 pt lines at 87 % with 1 and 2 pixels per point and compares the screen with the page rendered directly at the exact scale. On main: mean difference 69.5/255, max 216. Now: under 1, max under 16. 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
a747f2521f
commit
23bac79756
@@ -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 }))),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user