Render: round page rasters up so the last partial row and column are drawn (#102) (#114)

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) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
HEOJUNFO
2026-10-07 21:16:17 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 4454af3742
commit 9a1bf44e07
3 changed files with 54 additions and 5 deletions
+3 -1
View File
@@ -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.
+47 -1
View File
@@ -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<u8>, Option<Arc<crate::text::PageText>>);
@@ -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]
+4 -3
View File
@@ -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)));