Merge PR #3948: Reject rounded page rasters beyond the pixel budget

This commit is contained in:
aiongg
2026-10-22 01:52:27 +00:00
2 changed files with 153 additions and 1 deletions
+62
View File
@@ -12,6 +12,10 @@ pub struct Pixels(Vec<u32>);
impl Pixels {
/// Zeroed pixels for a `width` × `height` raster; `None` when the size overflows.
pub(crate) fn zeroed(width: usize, height: usize) -> Option<Self> {
#[cfg(test)]
if allocation_probe::refuse(width, height) {
return None;
}
Some(Self(vec![0; width.checked_mul(height)?]))
}
@@ -57,3 +61,61 @@ impl std::fmt::Debug for Pixels {
write!(f, "Pixels({} bytes)", self.len())
}
}
/// Observe the real allocator boundary without allocating a large raster in tests.
#[cfg(test)]
pub(crate) mod allocation_probe {
use std::cell::Cell;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct Attempts {
pub calls: u8,
pub first: Option<(usize, usize)>,
}
thread_local! {
static ACTIVE: Cell<Option<Attempts>> = const { Cell::new(None) };
}
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
ACTIVE.set(None);
}
}
pub(crate) fn observe<T>(f: impl FnOnce() -> T) -> (T, Attempts) {
assert!(ACTIVE.get().is_none(), "nested raster allocation probe");
let _reset = Reset;
ACTIVE.set(Some(Attempts::default()));
let result = f();
let attempts = ACTIVE.get().expect("active raster allocation probe");
(result, attempts)
}
pub(super) fn refuse(width: usize, height: usize) -> bool {
let Some(mut attempts) = ACTIVE.get() else { return false };
attempts.calls = attempts.calls.checked_add(1).expect("bounded allocation probe calls");
attempts.first.get_or_insert((width, height));
ACTIVE.set(Some(attempts));
true
}
#[test]
fn raster_allocation_probe_restores_after_unwind() {
let panic = std::panic::catch_unwind(|| {
observe(|| {
assert!(super::Pixels::zeroed(2, 3).is_none());
panic!("intentional test-harness unwind outside rendering");
});
});
assert!(panic.is_err());
assert!(ACTIVE.get().is_none());
assert_eq!(super::Pixels::zeroed(2, 3).expect("probe restored").len(), 24);
let (pixels, attempts) = observe(|| super::Pixels::zeroed(3, 2));
assert!(pixels.is_none());
assert_eq!(attempts, Attempts { calls: 1, first: Some((3, 2)) });
assert!(ACTIVE.get().is_none());
}
}
+91 -1
View File
@@ -60,7 +60,8 @@ pub struct RenderConfig {
/// View ▸ Hide all comments: markup annotations aren't drawn (fields and links still are).
pub hide_comments: bool,
/// Refuse a whole-page raster that would be reduced by the renderer's hard size caps.
/// Tiled requests remain available for pages that exceed those caps.
/// Tiled requests remain available for pages that exceed those caps. Both modes refuse
/// a raster whose rounded integer dimensions exceed the final pixel-allocation budget.
pub reject_oversize: bool,
/// View ▸ Reading colours: solid text and line-art paints are remapped for readability and
/// the page clears to the setting's background. Display only; the document never changes.
@@ -365,6 +366,9 @@ fn render_page<'a>(
};
// Rendered straight into a buffer of whole pixels, which the GUI takes over as is.
let (w, h) = render_size(page, &rs);
if u64::from(w).checked_mul(u64::from(h)).is_none_or(|pixels| pixels > MAX_PIXELS as u64) {
return Err(format!("page {} raster size {w}x{h} exceeds renderer pixel limit", req.page + 1));
}
let mut pixels = Pixels::zeroed(w.into(), h.into()).ok_or_else(|| format!("page {} is too large to render", req.page + 1))?;
// Previews render at low detail (tiny images are skipped); finals always draw all.
let mut preview_settings = settings.clone();
@@ -2801,6 +2805,92 @@ trailer << /Root 1 0 R >>
assert_eq!((tile.width, tile.height), (32, 32));
}
fn allocation_budget_page(width: f32, height: f32, content: &str) -> Arc<Vec<u8>> {
Arc::new(format!("%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {width} {height}] /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF", content.len()).into_bytes())
}
#[test]
fn rounded_pixel_budget_rejects_before_actual_allocation() {
use crate::pixels::allocation_probe::{Attempts, observe};
for reject_oversize in [false, true] {
let mut renderer = PageRenderer::new(allocation_budget_page(8000.5, 7999.5, ""), RenderConfig { reject_oversize, ..Default::default() });
assert_eq!(renderer.page_count(), 1);
let (out, attempts) = observe(|| renderer.render(RenderRequest { scale: 1.0, ..Default::default() }));
assert_eq!(attempts, Attempts::default(), "rounded over-cap raster reached allocator; strict={reject_oversize}");
assert_eq!(out.error.as_deref(), Some("page 1 raster size 8001x8000 exceeds renderer pixel limit"));
assert_eq!((out.width, out.height), (0, 0));
assert!(out.rgba.is_empty());
assert_eq!(renderer.stats().parser_builds, 1, "normal rejection must not trigger panic recovery");
}
}
#[test]
fn rounded_pixel_budget_allows_exact_cap_to_reach_allocator() {
use crate::pixels::allocation_probe::{Attempts, observe};
for reject_oversize in [false, true] {
let mut renderer = PageRenderer::new(allocation_budget_page(8000.0, 8000.0, ""), RenderConfig { reject_oversize, ..Default::default() });
let (out, attempts) = observe(|| renderer.render(RenderRequest { scale: 1.0, ..Default::default() }));
assert_eq!(attempts, Attempts { calls: 1, first: Some((8000, 8000)) });
// The test probe refuses the actual allocation: this is admission evidence,
// not a successful giant-raster rendering or a 256MB test allocation.
assert_eq!(out.error.as_deref(), Some("page 1 is too large to render"));
assert_eq!((out.width, out.height), (0, 0));
assert!(out.rgba.is_empty());
assert_eq!(renderer.stats().parser_builds, 1);
}
}
#[test]
fn rounded_pixel_budget_preserves_small_pixels_and_large_page_tiles() {
let mut previous = None;
for reject_oversize in [false, true] {
let config = RenderConfig { reject_oversize, ..Default::default() };
let mut small = PageRenderer::new(allocation_budget_page(16.0, 12.0, "0 0 1 rg 4 3 8 6 re f"), config.clone());
let out = small.render(RenderRequest { scale: 1.0, ..Default::default() });
assert!(out.error.is_none(), "{:?}", out.error);
assert_eq!((out.width, out.height), (16, 12));
let expected: Vec<u8> = (0..12)
.flat_map(|y| {
(0..16).flat_map(move |x| if (4..12).contains(&x) && (3..9).contains(&y) { [0, 0, 255, 255] } else { [255, 255, 255, 255] })
})
.collect();
assert_eq!(&*out.rgba, expected.as_slice());
if let Some(previous) = &previous {
assert_eq!(previous, &out.rgba);
}
previous = Some(out.rgba);
let mut large = PageRenderer::new(allocation_budget_page(8000.5, 7999.5, ""), config);
let tile = large.render(RenderRequest { scale: 1.0, tile: Some(Tile { x: 0, y: 0, w: 32, h: 32 }), ..Default::default() });
assert!(tile.error.is_none(), "{:?}", tile.error);
assert_eq!((tile.width, tile.height), (32, 32));
assert_eq!(&*tile.rgba, &[255; 32 * 32 * 4]);
assert_eq!(large.stats().parser_builds, 1);
}
}
#[test]
fn rounded_pixel_budget_invalid_requests_never_reach_allocator() {
use crate::pixels::allocation_probe::{Attempts, observe};
for reject_oversize in [false, true] {
let mut renderer = PageRenderer::new(allocation_budget_page(16.0, 12.0, ""), RenderConfig { reject_oversize, ..Default::default() });
let (out, attempts) = observe(|| renderer.render(RenderRequest { page: 1, scale: 1.0, ..Default::default() }));
assert_eq!(attempts, Attempts::default());
assert!(out.error.is_some());
assert_eq!((out.width, out.height), (0, 0));
assert!(out.rgba.is_empty());
assert_eq!(renderer.stats().parser_builds, 1);
}
let mut renderer = PageRenderer::new(allocation_budget_page(16.0, 12.0, ""), RenderConfig { reject_oversize: true, ..Default::default() });
for scale in [0.0, -1.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
let (out, attempts) = observe(|| renderer.render(RenderRequest { scale, ..Default::default() }));
assert_eq!(attempts, Attempts::default());
assert!(out.error.as_deref().is_some_and(|error| error.contains("exceeds renderer limits")));
assert_eq!((out.width, out.height), (0, 0));
assert!(out.rgba.is_empty());
assert_eq!(renderer.stats().parser_builds, 1);
}
}
/// 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]