From 9a1bf44e073449e443d95af9bc7f33005d29bdad Mon Sep 17 00:00:00 2001 From: HEOJUNFO <100739943+HEOJUNFO@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:16:17 +0900 Subject: [PATCH] Render: round page rasters up so the last partial row and column are drawn (#102) (#114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An A4 page (595.28×841.89 pt) rendered 595×841 at 72 dpi, where pdftoppm gives 596×842: the whole-page render passed hayro no size, and hayro floors the scaled page size. Content touching the right or bottom edge lost its last partial pixel at every resolution. - render: new device_pixels(pt, scale) rounds up (ignoring float noise up to 1/100 px, so Letter at 150 dpi stays 1275 px). The untiled render passes it to hayro as the width and height, still within MAX_SIDE. The partial edge pixels are anti-aliased against the white page, as the clip to the crop box already does. - ui-egui: the canvas tile grid uses the same helper instead of round(), so tiled pages get tiles for the partial row and column too. - No change to vendored hayro. Regression test: a fractional MediaBox renders 596×842 with partly covered edge pixels, 2481 px wide at 300 dpi, and a corner tile matches the whole-page render. It failed (595×841) before the fix. Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- crates/render/src/lib.rs | 4 ++- crates/render/src/raster.rs | 48 +++++++++++++++++++++++++++++++++++- crates/ui-egui/src/canvas.rs | 7 +++--- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 506dd89e0..eed877e8b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -16,7 +16,9 @@ pub use inspect::{ Annotation, Attachment, AttachmentSource, DocInfo, Field, FieldKind, FontInfo, Layer, Link, LinkTarget, OutlineItem, PageInfo, Xfa, attachment_data, inspect, pretty_date, }; -pub use raster::{MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderedPage, RequestKind, Tile, effective_scale}; +pub use raster::{ + MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderedPage, RequestKind, Tile, device_pixels, effective_scale, +}; pub use text::{PageText, TextGlyph}; /// Errors surfaced to the user when a document cannot be opened. diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 6560ffa4f..0a524d548 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -115,6 +115,15 @@ pub fn effective_scale(width_pt: f32, height_pt: f32, scale: f32) -> f32 { if capped.is_finite() && capped > 0.0 { capped } else { by_side.min(by_area).max(f32::MIN_POSITIVE) } } +/// Device pixels that cover `pt` points at `scale`: rounded up, as poppler's `pdftoppm` does, so +/// the last partial row or column of a page is drawn (anti-aliased against the background) rather +/// than cut off. Float noise up to 1/100 px is not rounded up, so 612 pt at 150 dpi stays 1275 px. +/// At least 1; saturates instead of overflowing. +pub fn device_pixels(pt: f32, scale: f32) -> u32 { + let px = pt * scale - 0.01; + if px.is_finite() { px.ceil().max(1.0) as u32 } else { 1 } +} + /// Render one page with a caller-owned parser and cache. Panics inside the renderer are caught /// and reported as `Err((message, panicked))`. type Output = (u32, u32, Vec, Option>); @@ -151,7 +160,10 @@ fn render_page<'a>(pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &Interpreter } None => { let scale = effective_scale(w, h, req.scale); - RenderSettings { x_scale: scale, y_scale: scale, bg_color: WHITE, ..Default::default() } + // hayro floors the size when none is given, losing the partial edge pixels. The + // scale keeps each side within MAX_SIDE (give or take float noise), so it fits u16. + let side = |pt: f32| device_pixels(pt, scale).min(MAX_SIDE as u32) as u16; + RenderSettings { x_scale: scale, y_scale: scale, width: Some(side(w)), height: Some(side(h)), bg_color: WHITE, ..Default::default() } } }; let pixmap = render(page, cache, settings, &rs); @@ -560,6 +572,40 @@ trailer << /Root 1 0 R >> assert!((14_400.0 * s).powi(2) <= MAX_PIXELS * 1.01); } + /// Issue #102: an A4 page (595.28×841.89 pt) rendered 595×841 at 72 dpi, dropping the last + /// partial row and column. Like pdftoppm it is now 596×842, the edge pixels partly covered. + #[test] + fn fractional_page_sizes_round_up() { + let pdf = b"%PDF-1.4 +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 595.28 841.89] /Contents 4 0 R >> endobj +4 0 obj << /Length 31 >> stream +0 0 1 rg 0 0 595.28 841.89 re f +endstream endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default()); + let req = RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }; + let p = r.render(req); + assert!(p.error.is_none(), "{:?}", p.error); + assert_eq!((p.width, p.height), (596, 842)); + let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); + assert_eq!(px(594, 840), vec![0, 0, 255, 255]); + // The partial column and row blend the fill into the white page. + for (x, y) in [(595, 400), (300, 841), (595, 841)] { + let c = px(x, y); + assert!(c[0] < 255 && c[2] == 255, "({x}, {y}) is not partly covered: {c:?}"); + } + assert_eq!(r.render(RenderRequest { scale: 300.0 / 72.0, ..req }).width, 2481); + // A tile over the corner draws the same edge pixels. + let t = r.render(RenderRequest { tile: Some(Tile { x: 594, y: 840, w: 2, h: 2 }), ..req }); + assert_eq!((t.width, t.height), (2, 2)); + assert_eq!(&t.rgba[12..16], &px(595, 841)[..]); + // Float noise doesn't add a row: Letter at 150 dpi is 1275 px wide. + assert_eq!((device_pixels(612.0, 150.0 / 72.0), device_pixels(0.2, 1.0), device_pixels(f32::NAN, 1.0)), (1275, 1, 1)); + } + /// A checkbox whose `/AP /N` is a state dictionary must draw the `/AS` state; a NoView /// annotation must not draw at all. (Regression test for the vendored hayro patch.) #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 55d72449a..e3c4a4c87 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHandle, TextureOptions, Vec2, pos2, vec2}; use pdfcraft_engine::{DocId, Edit}; -use pdfcraft_render::{DocInfo, LinkTarget, PageText, RenderPool, RenderRequest, RequestKind, Tile}; +use pdfcraft_render::{DocInfo, LinkTarget, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels}; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets}; @@ -1135,8 +1135,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } if tiled && r.intersects(visible) { // Device-pixel geometry of the scaled page, and the visible part of it - // (found by mapping the visible screen corners back into the page). - let (dw, dh) = ((pw_pt * scale).round() as u32, (ph_pt * scale).round() as u32); + // (found by mapping the visible screen corners back into the page). Sides round + // up as whole-page rasters do, so the last partial row and column get tiles. + let (dw, dh) = (device_pixels(pw_pt, scale), device_pixels(ph_pt, scale)); let vis = r.intersect(visible); let corners = [vis.left_top(), vis.right_top(), vis.right_bottom(), vis.left_bottom()].map(|c| xf.screen_to_norm(c)); let (u0, u1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.0), b.max(c.0)));