//! Background page rasterization. //! //! A `RenderPool` owns N worker threads. Each worker parses the shared bytes once (hayro's parser //! is lazy, so this is cheap) and keeps its own render cache. Requests carry a caller tag that is //! echoed back; the UI drops results for stale tags (e.g. after a zoom change). //! //! **Robustness:** every page render runs under `catch_unwind`. A panic inside the renderer on a //! malformed page yields a `RenderedPage` with `error` set, and the worker rebuilds its parser and //! cache before continuing, so one bad page can never take down the app or poison later pages. use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::mpsc::{Receiver, Sender, channel}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use hayro::hayro_interpret::InterpreterSettings; use hayro::hayro_syntax::Pdf; use hayro::vello_cpu::color::palette::css::WHITE; use hayro::{RenderCache, RenderSettings, render}; /// Monotonic-ish timer that is safe on wasm32 (where `std::time::Instant` panics). #[derive(Clone, Copy)] struct Stopwatch(#[cfg(not(target_arch = "wasm32"))] std::time::Instant); impl Stopwatch { fn start() -> Self { #[cfg(not(target_arch = "wasm32"))] return Self(std::time::Instant::now()); #[cfg(target_arch = "wasm32")] return Self(); } fn millis(self) -> u32 { #[cfg(not(target_arch = "wasm32"))] return self.0.elapsed().as_millis() as u32; #[cfg(target_arch = "wasm32")] return 0; } } /// Per-document rendering configuration. #[derive(Clone, Debug, Default)] pub struct RenderConfig { /// User or owner password for encrypted documents. pub password: Option>, /// Layer (optional content group) visibility overrides: (object number, generation, visible). pub layers: Arc>, /// View ▸ Hide all comments: markup annotations aren't drawn (fields and links still are). pub hide_comments: bool, } impl RenderConfig { fn settings(&self) -> InterpreterSettings { InterpreterSettings { ocg_overrides: self.layers.clone(), hide_comments: self.hide_comments, ..InterpreterSettings::default() } } } /// Hard cap on a rendered side, to bound memory at extreme zoom levels (tiling arrives in M3.3). pub const MAX_SIDE: f32 = 8192.0; /// Hard cap on rendered pixels per page (~64 MP ≈ 256 MB RGBA). pub const MAX_PIXELS: f32 = 64.0e6; /// What a request asks for. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] pub enum RequestKind { /// A raster of the page. #[default] Pixels, /// The page's text layer (glyphs with Unicode and boxes); `scale` is ignored. Text, } /// A region of a scaled page, in device pixels (for tiled rendering of large pages). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct Tile { pub x: u32, pub y: u32, pub w: u32, pub h: u32, } #[derive(Clone, Copy, Debug, PartialEq, Default)] pub struct RenderRequest { pub page: usize, pub kind: RequestKind, /// Render only this region (device pixels at `scale`). `None` renders the whole page. pub tile: Option, /// Device pixels per PDF point. pub scale: f32, /// Caller-defined tag, echoed back (used for zoom generations / thumbnail vs page). pub tag: u64, } #[derive(Debug)] pub struct RenderedPage { pub request: RenderRequest, pub width: u32, pub height: u32, /// Premultiplied RGBA8, row-major. Empty when `error` is set. pub rgba: Vec, /// Why the page could not be rendered (renderer panic, empty page box, …). pub error: Option, /// For `RequestKind::Text`. pub text: Option>, pub millis: u32, } /// Clamp a requested scale so the output respects `MAX_SIDE` and `MAX_PIXELS`. pub fn effective_scale(width_pt: f32, height_pt: f32, scale: f32) -> f32 { let (w, h) = (width_pt.max(1.0), height_pt.max(1.0)); let by_side = MAX_SIDE / w.max(h); let by_area = (MAX_PIXELS / (w * h)).sqrt(); // The caps always win: a floor here once let a 934-million-point-wide page (a fuzzed file) // render 9 million pixels wide. Only guard against a zero or non-finite scale. let capped = scale.min(by_side).min(by_area); 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>); fn render_page<'a>(pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &InterpreterSettings, req: RenderRequest) -> Result { if req.kind == RequestKind::Text { return match catch_unwind(AssertUnwindSafe(|| crate::text::extract_page(pdf, req.page, settings))) { Ok(Some(t)) => Ok((0, 0, Vec::new(), Some(Arc::new(t)))), Ok(None) => Err((format!("page {} does not exist", req.page + 1), false)), Err(panic) => Err((format!("text extraction crashed on page {}: {}", req.page + 1, panic_message(&panic)), true)), }; } let result = catch_unwind(AssertUnwindSafe(|| { let pages = pdf.pages(); let page = pages.get(req.page).ok_or_else(|| format!("page {} does not exist", req.page + 1))?; let (w, h) = page.render_dimensions(); if !(w.is_finite() && h.is_finite()) || w < 0.5 || h < 0.5 { return Err(format!("page {} has an empty or invalid page box ({w}×{h} pt)", req.page + 1)); } let rs = match req.tile { // Tiles are bounded by construction, so the page itself may be arbitrarily large. Some(t) => { let scale = req.scale.clamp(0.01, 400.0); let (tw, th) = (t.w.clamp(1, 4096), t.h.clamp(1, 4096)); RenderSettings { x_scale: scale, y_scale: scale, width: Some(tw as u16), height: Some(th as u16), x_offset: t.x as f32, y_offset: t.y as f32, bg_color: WHITE, } } None => { let scale = effective_scale(w, h, req.scale); // 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); Ok((pixmap.width() as u32, pixmap.height() as u32, pixmap.data_as_u8_slice().to_vec(), None)) })); match result { Ok(Ok(v)) => Ok(v), Ok(Err(e)) => Err((e, false)), Err(panic) => Err((format!("renderer crashed on page {}: {}", req.page + 1, panic_message(&panic)), true)), } } fn finish(req: RenderRequest, start: Stopwatch, r: Result) -> RenderedPage { let millis = start.millis(); match r { Ok((width, height, rgba, text)) => RenderedPage { request: req, width, height, rgba, error: None, text, millis }, Err((e, _)) => RenderedPage { request: req, width: 0, height: 0, rgba: Vec::new(), error: Some(e), text: None, millis }, } } /// A single-threaded renderer over one parsed document (used by the CLI and tests). pub struct PageRenderer { bytes: Arc>, config: RenderConfig, pdf: Option, settings: InterpreterSettings, } impl PageRenderer { pub fn new(bytes: Arc>, config: RenderConfig) -> Self { let pdf = parse(&bytes, config.password.as_deref()); let settings = config.settings(); Self { bytes, config, pdf, settings } } pub fn page_count(&self) -> usize { self.pdf.as_ref().map(|p| p.pages().len()).unwrap_or(0) } /// Render one page. Never panics. pub fn render(&mut self, req: RenderRequest) -> RenderedPage { let start = Stopwatch::start(); let Some(pdf) = self.pdf.as_ref() else { return finish(req, start, Err(("the document could not be parsed".into(), false))) }; let cache = RenderCache::new(); let r = render_page(pdf, &cache, &self.settings, req); if matches!(r, Err((_, true))) { self.pdf = parse(&self.bytes, self.config.password.as_deref()); } finish(req, start, r) } } fn parse(bytes: &Arc>, password: Option<&str>) -> Option { catch_unwind(AssertUnwindSafe(|| Pdf::new_with_password(bytes.clone(), password.unwrap_or("")).ok())).ok().flatten() } pub(crate) fn panic_message(p: &Box) -> String { p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "unknown panic".into()) } /// How long one page may render before the pool gives up on it (the watchdog). pub const STUCK_AFTER: std::time::Duration = std::time::Duration::from_secs(20); /// State shared by the pool and its workers. #[derive(Default)] struct Shared { /// Pending requests, most urgent last (workers pop from the end). queue: Mutex>, /// Per worker id: the request it is rendering and since when. #[cfg(not(target_arch = "wasm32"))] busy: Mutex>>, /// Pages (and request kinds) the watchdog gave up on: answered with an error at once, so a /// pathological page cannot trap every worker in turn. stuck: Mutex>, /// Test hook: make one page slow. #[cfg(test)] slow_page: Mutex>, } fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { m.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) } /// Renders pages on worker threads, most urgent request first. /// /// **Watchdog:** a render running longer than `stuck_after` (default [`STUCK_AFTER`]) is reported /// as an error for that page, the page is not attempted again, and a replacement worker takes /// the stuck one's place (threads cannot be killed; the stuck one exits when its render /// finally returns, and its late result is dropped). At most `threads` replacements are started /// per pool, so a document full of pathological pages cannot spawn threads without bound. pub struct RenderPool { shared: Arc, wake: Mutex>>, results: Receiver, results_tx: Sender, bytes: Arc>, config: RenderConfig, _workers: Mutex>>, /// Errors produced by the watchdog, handed out by `try_recv`. abandoned: Mutex>, stuck_after: std::time::Duration, replacements_left: Mutex, /// Used when threads are unavailable (wasm32 without atomics, or spawn failure): requests are /// rendered on the calling thread inside `try_recv`, one per call. inline: Option>, } impl RenderPool { pub fn new(bytes: Arc>, threads: usize, config: RenderConfig) -> Self { let (results_tx, results) = channel(); let threads = if cfg!(target_arch = "wasm32") { 0 } else { threads.max(1) }; let mut pool = Self { shared: Arc::default(), wake: Mutex::new(Vec::new()), results, results_tx, bytes: bytes.clone(), config: config.clone(), _workers: Mutex::new(Vec::new()), abandoned: Mutex::new(Vec::new()), stuck_after: STUCK_AFTER, replacements_left: Mutex::new(threads), inline: None, }; for _ in 0..threads { pool.spawn_worker(); } if lock(&pool._workers).is_empty() { pool.inline = Some(std::cell::RefCell::new(PageRenderer::new(bytes, config))); } pool } /// Start one worker thread; returns whether it started. fn spawn_worker(&self) -> bool { #[cfg(target_arch = "wasm32")] return false; #[cfg(not(target_arch = "wasm32"))] { let id = { let mut busy = lock(&self.shared.busy); busy.push(None); busy.len() - 1 }; let (wtx, wrx) = channel::<()>(); let (shared, out, bytes, config) = (self.shared.clone(), self.results_tx.clone(), self.bytes.clone(), self.config.clone()); match std::thread::Builder::new().name(format!("pdfcraft-render-{id}")).spawn(move || worker(id, bytes, config, shared, wrx, out)) { Ok(h) => { lock(&self.wake).push(wtx); lock(&self._workers).push(h); true } Err(e) => { log::error!("could not spawn render worker {id}: {e}"); false } } } } /// A pool without worker threads: renders inside `try_recv` (the web path; also used in tests). pub fn new_inline(bytes: Arc>, config: RenderConfig) -> Self { let mut pool = Self::new(bytes.clone(), 0, config.clone()); if pool.inline.is_none() { // Native `new` always spawns at least one worker; drop to inline explicitly. pool = Self { shared: Arc::default(), wake: Mutex::new(Vec::new()), _workers: Mutex::new(Vec::new()), ..pool }; pool.inline = Some(std::cell::RefCell::new(PageRenderer::new(bytes, config))); } pool } /// `true` when rendering happens on the caller's thread (no worker threads available). pub fn is_inline(&self) -> bool { self.inline.is_some() } /// Change the watchdog limit (tests and benchmarks). pub fn set_stuck_after(&mut self, limit: std::time::Duration) { self.stuck_after = limit; } /// Replace the pending queue (most urgent first). In-flight renders are not interrupted. pub fn set_queue(&self, mut requests: Vec) { requests.reverse(); // workers pop from the end *lock(&self.shared.queue) = requests; for w in lock(&self.wake).iter() { let _ = w.send(()); } } pub fn try_recv(&self) -> Option { if let Some(r) = &self.inline { let next = lock(&self.shared.queue).pop(); return next.map(|req| r.borrow_mut().render(req)); } self.watchdog(); if let Some(p) = lock(&self.abandoned).pop() { return Some(p); } self.results.try_recv().ok() } /// Give up on renders that exceeded `stuck_after` (see the type docs). fn watchdog(&self) { #[cfg(not(target_arch = "wasm32"))] { let mut gave_up = Vec::new(); { let mut busy = lock(&self.shared.busy); for slot in busy.iter_mut() { if let Some((req, since)) = *slot && since.elapsed() > self.stuck_after { *slot = None; // the worker sees this and exits when it returns gave_up.push(req); } } } for req in gave_up { lock(&self.shared.stuck).insert((req.page, req.kind)); let what = if req.kind == RequestKind::Text { "text extraction for page" } else { "page" }; let error = format!( "{what} {} took longer than {:.0} s and was skipped; the page may be damaged or extremely complex", req.page + 1, self.stuck_after.as_secs_f32().max(1.0) ); lock(&self.abandoned).push(RenderedPage { request: req, width: 0, height: 0, rgba: Vec::new(), error: Some(error), text: None, millis: 0, }); let mut left = lock(&self.replacements_left); if *left > 0 && self.spawn_worker() { *left -= 1; } } } } } #[cfg(not(target_arch = "wasm32"))] fn worker(id: usize, bytes: Arc>, config: RenderConfig, shared: Arc, wake: Receiver<()>, out: Sender) { let settings = config.settings(); // Outer loop: (re)build parser + cache; rebuilt after a renderer panic. loop { let pdf = parse(&bytes, config.password.as_deref()); let cache = RenderCache::new(); loop { let next = lock(&shared.queue).pop(); let Some(req) = next else { if wake.recv().is_err() { return; // pool dropped } continue; }; if lock(&shared.stuck).contains(&(req.page, req.kind)) { let error = format!("page {} was skipped earlier because it took too long to render", req.page + 1); let page = RenderedPage { request: req, width: 0, height: 0, rgba: Vec::new(), error: Some(error), text: None, millis: 0 }; if out.send(page).is_err() { return; } continue; } let start = Stopwatch::start(); if let Some(slot) = lock(&shared.busy).get_mut(id) { *slot = Some((req, std::time::Instant::now())); } #[cfg(test)] { // Copy out first: a guard held in the `if let` would block the other workers. let slow = *lock(&shared.slow_page); if let Some((page, delay)) = slow && page == req.page { std::thread::sleep(delay); } } let r = match &pdf { Some(pdf) => render_page(pdf, &cache, &settings, req), None => Err(("the document could not be parsed".into(), false)), }; // If the watchdog cleared our slot meanwhile, it already answered for this request // and started a replacement: drop the late result and retire. let abandoned = lock(&shared.busy).get_mut(id).is_none_or(|slot| slot.take().is_none()); if abandoned { return; } let panicked = matches!(r, Err((_, true))); if out.send(finish(req, start, r)).is_err() { return; } if panicked { break; } } } } #[cfg(test)] mod tests { #[test] fn watchdog_skips_a_stuck_page_and_keeps_rendering() { use super::*; let mut pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()); pool.set_stuck_after(std::time::Duration::from_millis(1000)); *lock(&pool.shared.slow_page) = Some((0, std::time::Duration::from_secs(4))); let req = |page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0 }; pool.set_queue(vec![req(0)]); let recv = |pool: &RenderPool| { let t = std::time::Instant::now(); loop { if let Some(p) = pool.try_recv() { return p; } assert!(t.elapsed() < std::time::Duration::from_secs(8), "no result"); std::thread::sleep(std::time::Duration::from_millis(5)); } }; let started = std::time::Instant::now(); let first = recv(&pool); assert!(started.elapsed() < std::time::Duration::from_millis(3000), "the watchdog answers before the render ends"); assert!(first.error.as_deref().is_some_and(|e| e.contains("took longer")), "{:?}", first.error); // The single worker is stuck, yet page 2 still renders (on the replacement worker). pool.set_queue(vec![req(1)]); let second = recv(&pool); assert!(second.error.is_none() && second.request.page == 1, "{:?}", second.error); // Asking for the stuck page again fails fast instead of trapping another worker. pool.set_queue(vec![req(0)]); let again = recv(&pool); assert!(again.error.as_deref().is_some_and(|e| e.contains("skipped earlier")), "{:?}", again.error); // The stuck worker's late result is dropped, not delivered twice. std::thread::sleep(std::time::Duration::from_millis(4200)); assert!(pool.try_recv().is_none()); } #[test] fn effective_scale_never_exceeds_the_caps() { for (w, h, s) in [(934_775_807.0, 792.0, 0.25), (612.0, 792.0, 0.0), (1.0e9, 1.0e9, 1.0), (612.0, 792.0, 2.0)] { let k = super::effective_scale(w, h, s); assert!(k > 0.0 && k.is_finite(), "{w}×{h} @ {s}: {k}"); assert!(w * k <= super::MAX_SIDE + 1.0 && h * k <= super::MAX_SIDE + 1.0, "{w}×{h} @ {s}: {k}"); assert!(w * k * h * k <= super::MAX_PIXELS * 1.01, "{w}×{h} @ {s}: {k}"); } assert_eq!(super::effective_scale(612.0, 792.0, 2.0), 2.0); } use super::*; const ONE_PAGE: &[u8] = 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 100 50] /Contents 4 0 R >> endobj 4 0 obj << /Length 35 >> stream 0 0 1 rg 10 10 30 20 re f endstream endobj trailer << /Root 1 0 R >> %%EOF"; const ONE_PAGE_TWICE: &[u8] = b"%PDF-1.4 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj 2 0 obj << /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >> endobj 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Contents 4 0 R >> endobj 4 0 obj << /Length 35 >> stream 0 0 1 rg 10 10 30 20 re f endstream endobj 5 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Contents 4 0 R >> endobj trailer << /Root 1 0 R >> %%EOF"; #[test] fn renders_a_blue_rectangle() { let pool = RenderPool::new(Arc::new(ONE_PAGE.to_vec()), 1, RenderConfig::default()); pool.set_queue(vec![RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 7 }]); let page = loop { if let Some(p) = pool.try_recv() { break p; } std::thread::sleep(std::time::Duration::from_millis(5)); }; assert!(page.error.is_none(), "{:?}", page.error); assert_eq!((page.width, page.height, page.request.tag), (100, 50, 7)); // Pixel (20, 30) in y-down device space is inside the rect drawn at y 10..30 (y-up). let px = |x: u32, y: u32| &page.rgba[((y * page.width + x) * 4) as usize..][..4]; assert_eq!(px(20, 30), &[0, 0, 255, 255]); assert_eq!(px(80, 5), &[255, 255, 255, 255]); } #[test] fn missing_page_and_garbage_input_fail_gracefully() { let mut r = PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default()); assert!(r.render(RenderRequest { page: 9, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }).error.is_some()); let mut g = PageRenderer::new(Arc::new(b"not a pdf at all".to_vec()), RenderConfig::default()); assert_eq!(g.page_count(), 0); assert!(g.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }).error.is_some()); } #[test] fn scale_is_clamped_for_huge_pages() { // A 200-inch-square page at 4x would be 57,600 px per side. let s = effective_scale(14_400.0, 14_400.0, 4.0); assert!(14_400.0 * s <= MAX_SIDE + 0.5); 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] fn widget_appearance_states() { let pdf = b"%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 100 100] /Annots [4 0 R 7 0 R] >> endobj 4 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (cb) /V /Yes /AS /Yes /Rect [10 10 30 30] /AP << /N << /Yes 5 0 R /Off 6 0 R >> >> >> endobj 5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 24 >> stream 1 0 0 rg 0 0 20 20 re f endstream endobj 6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 24 >> stream 0 0 1 rg 0 0 20 20 re f endstream endobj 7 0 obj << /Type /Annot /Subtype /Square /F 32 /Rect [60 60 90 90] /AP << /N 8 0 R >> >> endobj 8 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 24 >> stream 0 1 0 rg 0 0 30 30 re f endstream endobj trailer << /Root 1 0 R >> %%EOF"; let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default()); let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }); assert!(p.error.is_none(), "{:?}", p.error); let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); assert_eq!(px(20, 80), vec![255, 0, 0, 255], "checkbox must show its /Yes state"); assert_eq!(px(75, 25), vec![255, 255, 255, 255], "NoView annotation must not be drawn"); } /// Quartz writes `/AP /N` as an indirect dictionary of states, and the next object in /// the file is often some other stream. That dictionary must not be read as that stream, /// or the checkbox (whose real appearance is `/AS`) is skipped. #[test] fn indirect_appearance_state_dict_is_not_another_stream() { let pdf = b"%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 100 100] /Annots [4 0 R] >> endobj 4 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (cb) /V /Yes /AS /Yes /Rect [10 10 30 30] /AP 9 0 R >> endobj 9 0 obj << /N 10 0 R >> endobj 10 0 obj << /Yes 5 0 R /Off 6 0 R >> endobj 11 0 obj << /Length 24 >> stream 0 0 1 rg 0 0 20 20 re f endstream endobj 5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 24 >> stream 1 0 0 rg 0 0 20 20 re f endstream endobj 6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 24 >> stream 0 1 0 rg 0 0 20 20 re f endstream endobj trailer << /Root 1 0 R >> %%EOF"; let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default()); let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }); assert!(p.error.is_none(), "{:?}", p.error); let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); assert_eq!(px(20, 80), vec![255, 0, 0, 255], "checkbox must show the /Yes appearance, not the decoy stream"); } #[test] fn hiding_comments_keeps_fields() { let pdf = b"%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 100 100] /Annots [4 0 R 6 0 R] >> endobj 4 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (t) /Rect [10 10 30 30] /AP << /N 5 0 R >> >> endobj 5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 24 >> stream 1 0 0 rg 0 0 20 20 re f endstream endobj 6 0 obj << /Type /Annot /Subtype /Square /F 4 /Rect [60 60 90 90] /AP << /N 7 0 R >> >> endobj 7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 24 >> stream 0 1 0 rg 0 0 30 30 re f endstream endobj trailer << /Root 1 0 R >> %%EOF"; let render = |hide: bool| { let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig { hide_comments: hide, ..RenderConfig::default() }); r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }) }; let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); let shown = render(false); assert_eq!(px(&shown, 75, 25), vec![0, 255, 0, 255], "the comment is drawn"); let hidden = render(true); assert_eq!(px(&hidden, 75, 25), vec![255, 255, 255, 255], "Hide all comments"); assert_eq!(px(&hidden, 20, 80), vec![255, 0, 0, 255], "fields stay"); } #[test] fn inline_pool_renders_in_priority_order() { let pool = RenderPool::new_inline(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default()); assert!(pool.is_inline()); assert!(pool.try_recv().is_none()); pool.set_queue(vec![ RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 1 }, RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 2 }, ]); assert_eq!(pool.try_recv().map(|p| p.request.tag), Some(1)); assert_eq!(pool.try_recv().map(|p| (p.request.tag, p.width)), Some((2, 50))); assert!(pool.try_recv().is_none()); } #[test] fn extracts_text_with_positions() { let pdf = b"%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 200 100] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj 4 0 obj << /Length 60 >> stream BT /F1 12 Tf 20 70 Td (Hello World) Tj 0 -30 Td (Second) Tj ET endstream endobj 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj trailer << /Root 1 0 R >> %%EOF"; let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default()); let out = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 }); assert!(out.error.is_none(), "{:?}", out.error); let t = out.text.expect("text"); assert_eq!(t.plain_text(), "Hello World\nSecond"); // "H" sits at x=20, baseline y=70 (y-up) → view y ≈ 100-70 = 30 at the baseline. let h = &t.glyphs[0].rect; assert!((h[0] - 20.0).abs() < 0.5 && h[1] < 30.0 && h[3] > 30.0, "{h:?}"); assert_eq!(t.find("world").len(), 1); } #[test] fn layer_override_hides_content() { let pdf = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /Order [5 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 100 100] /Contents 4 0 R /Resources << /Properties << /L1 5 0 R >> >> >> endobj 4 0 obj << /Length 44 >> stream /OC /L1 BDC 1 0 0 rg 0 0 100 100 re f EMC endstream endobj 5 0 obj << /Type /OCG /Name (Red) >> endobj trailer << /Root 1 0 R >> %%EOF"; let px = |cfg: RenderConfig| { let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), cfg); let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0 }); p.rgba[..4].to_vec() }; assert_eq!(px(RenderConfig::default()), vec![255, 0, 0, 255], "layer is on by default"); let off = RenderConfig { layers: Arc::new(vec![(5, 0, false)]), ..Default::default() }; assert_eq!(px(off), vec![255, 255, 255, 255], "toggled-off layer must not draw"); } /// From `cargo xtask fuzz`: a tiling pattern with no /Resources inherits the page's, where /// its own name points back at it. It painted itself until the stack overflowed (vendored /// hayro-interpret patch: `MAX_PAINT_NESTING`). Ordinary patterns must still paint. #[test] fn self_referencing_tiling_pattern_terminates() { let pdf = |pattern_body: &str| { 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 40 40] /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R >> >> >> endobj 4 0 obj << /Length 30 >> stream /Pattern cs /P1 scn 0 0 40 40 re f endstream endobj 5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Length {} >> stream {pattern_body} endstream endobj trailer << /Root 1 0 R >> %%EOF", pattern_body.len() ) }; let render = |doc: String| { let mut r = PageRenderer::new(Arc::new(doc.into_bytes()), RenderConfig::default()); r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }) }; let looped = render(pdf("/Pattern cs /P1 scn 0 0 10 10 re f")); assert_eq!((looped.width, looped.height), (40, 40)); let red = render(pdf("1 0 0 rg 0 0 10 10 re f")); assert!(red.error.is_none(), "{:?}", red.error); assert_eq!(&red.rgba[((20 * 40 + 20) * 4)..][..4], &[255, 0, 0, 255], "a normal tiling pattern still paints"); } /// From `cargo xtask fuzz`: a Type 3 font without /Resources inherits the page's, where its /// own name is defined, and its glyph shows text in itself (vendored patch: /// `MAX_PAINT_NESTING`). Must terminate; a normal Type 3 glyph must still paint. #[test] fn self_referencing_type3_glyph_terminates() { let pdf = |proc_body: &str| { 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 40 40] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj 4 0 obj << /Length 31 >> stream BT /F1 20 Tf 10 10 Td (A) Tj ET endstream endobj 5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 1 1] /FontMatrix [1 0 0 1 0 0] /FirstChar 65 /LastChar 65 /Widths [1] /Encoding << /Differences [65 /a] >> /CharProcs << /a 6 0 R >> >> endobj 6 0 obj << /Length {} >> stream {proc_body} endstream endobj trailer << /Root 1 0 R >> %%EOF", proc_body.len() ) }; let render = |doc: String| { let mut r = PageRenderer::new(Arc::new(doc.into_bytes()), RenderConfig::default()); r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }) }; let looped = render(pdf("1 0 d0 BT /F1 1 Tf (A) Tj ET 0 0 1 1 re f")); assert_eq!((looped.width, looped.height), (40, 40)); let plain = render(pdf("1 0 d0 0 0 1 1 re f")); assert!(plain.error.is_none(), "{:?}", plain.error); assert!(plain.rgba.as_chunks::<4>().0.iter().any(|p| p[0] < 128), "a normal Type 3 glyph still paints"); } /// From `cargo xtask fuzz`: a tiling pattern whose /XStep and /YStep dwarf its /BBox got a /// cell pixmap of `step × scale` pixels, bounded only by u16 (2 GB and more). The vendored /// hayro patch `tiling_cell_scale` keeps the cell within 3000 pixels a side. #[test] fn tiling_cells_stay_small_whatever_the_step() { assert_eq!(hayro::tiling_cell_scale(2.0, 10.0, 3000.0), 2.0, "ordinary cells keep their scale"); for step in [1.0e5f32, -1.0e5, 4.0e9] { let s = hayro::tiling_cell_scale(2.0, step, 3000.0); assert!(s * step.abs() <= 3000.5, "{step}: {s}"); } assert_eq!(hayro::tiling_cell_scale(2.0, f32::INFINITY, 3000.0), 2.0); assert_eq!(hayro::tiling_cell_scale(2.0, 0.0, 3000.0), 2.0); // End to end: such a pattern renders, and the page's other content still draws. let content = "/Pattern cs /P1 scn 0 0 40 40 re f 1 0 0 rg 0 0 4 4 re f"; 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 40 40] /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R >> >> >> endobj 4 0 obj << /Length {} >> stream {content} endstream endobj 5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 100000 /YStep 100000 /Length 20 >> stream 0 0 1 rg 0 0 10 10 re f endstream endobj trailer << /Root 1 0 R >> %%EOF", content.len() ); let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }); assert!(page.error.is_none(), "{:?}", page.error); assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255]); } /// From the nightly `cargo xtask fuzz` (CI caps each child at 4 GiB): a stencil mask claiming /// /W 4294967295 and a CCITT image claiming /Columns 4294967295 each allocated 4 GiB while /// decoding (locally: 9.6 GB and 4.3 GB). Vendored hayro patches `image_size_ok` and /// `ccitt_size_ok` refuse such sizes before decoding; the rest of the page still draws. #[test] fn absurd_mask_and_fax_sizes_are_refused_before_decoding() { assert!(hayro::hayro_interpret::image_size_ok(8000, 8000)); assert!(!hayro::hayro_interpret::image_size_ok(4_294_967_295, 2)); assert!(!hayro::hayro_interpret::image_size_ok(0, 10)); assert!(hayro::hayro_syntax::ccitt_size_ok(1728, 2200), "a fax page"); assert!(!hayro::hayro_syntax::ccitt_size_ok(4_294_967_295, 26)); assert!(!hayro::hayro_syntax::ccitt_size_ok(1 << 19, 1 << 12)); let page = |content: &str, xobject: &str| { 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 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj 4 0 obj << /Length {} >> stream {content} endstream endobj 5 0 obj {xobject} endobj trailer << /Root 1 0 R >> %%EOF", content.len() ); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })); }); let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("an absurd image must not stall the renderer"); assert!(page.error.is_none(), "{:?}", page.error); assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws"); }; let red = "1 0 0 rg 0 0 4 4 re f"; page(&format!("q 20 0 0 20 5 5 cm BI /W 4294967295 /H 2 /IM true /BPC 1 ID \u{0}\u{ff}\u{ff}\u{0} EI Q {red}"), "<< >>"); let fax = "<< /Type /XObject /Subtype /Image /Width 81 /Height 26 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /CCITTFaxDecode /DecodeParms << /Columns 4294967295 /Rows 26 /K -1 >> /Length 4 >> stream\n\u{0}\u{0}\u{0}\u{0}\nendstream"; page(&format!("q 20 0 0 20 5 5 cm /Im1 Do Q {red}"), fax); } /// From the nightly `cargo xtask fuzz`: a FlateDecode predictor with `/Columns /// 9223372036854775807` wrapped to a 2^61-byte row allocation (an abort on any machine), and /// a line width of 9223372036854775807 made stroke expansion allocate 10 GB. Vendored hayro /// patches: saturating predictor rows refused when longer than the data, and stroke widths /// clamped to a few canvases. The page still renders, with the huge stroke covering it. #[test] fn absurd_predictor_columns_and_line_widths_render() { let render = |streams: &[&str]| { let mut objs = String::new(); let mut refs = Vec::new(); for (i, s) in streams.iter().enumerate() { let n = 4 + i; refs.push(format!("{n} 0 R")); objs.push_str(&format!("{n} 0 obj {s} endobj\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 40 40] /Contents [{}] >> endobj {objs}trailer << /Root 1 0 R >> %%EOF", refs.join(" ") ); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })); }); let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("must not stall the renderer"); assert!(page.error.is_none(), "{:?}", page.error); page }; let plain = |content: &str| format!("<< /Length {} >> stream\n{content}\nendstream", content.len()); // Stored (uncompressed) zlib data, so the stream needs no encoder: header, one final // stored block of two bytes, checksum. let predicted = "<< /Filter /FlateDecode /DecodeParms << /Predictor 12 /Columns 9223372036854775807 >> /Length 13 >> stream\nx\u{1}\u{1}\u{2}\u{0}\u{fd}\u{ff}\u{0}\u{0}\u{0}\u{1}\u{0}\u{1}\nendstream"; let page = render(&[predicted, &plain("1 0 0 rg 0 0 4 4 re f")]); assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the other content stream draws"); let page = render(&[&plain("1 0 0 RG 9223372036854775807 w 10 20 m 30 20 l S")]); // Butt caps: the stroke covers the band over the segment (x 10 to 30) top to bottom. for (x, y) in [(20, 0), (20, 39), (12, 0), (28, 39)] { assert_eq!(&page.rgba[((y * 40 + x) * 4)..][..4], &[255, 0, 0, 255], "({x}, {y}) under the huge stroke"); } assert_eq!(&page.rgba[((20 * 40 + 2) * 4)..][..4], &[255, 255, 255, 255], "beyond the butt cap"); } /// From the nightly `cargo xtask fuzz`: a Type 3 glyph that shows several glyphs of its own /// font. The nesting cap bounds the depth but not the breadth: eight glyphs per glyph, sixteen /// deep, is 8^16 paints. Vendored hayro-interpret patch: nested paints (forms, Type 3 glyphs, /// tiling patterns inside one another) share a per-page budget. #[test] fn type3_glyphs_that_fan_out_into_themselves_finish() { let pdf = "%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 40 40] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj 4 0 obj << /Length 53 >> stream BT /F1 20 Tf 10 10 Td (A) Tj ET 1 0 0 rg 0 0 4 4 re f endstream endobj 5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 1 1] /FontMatrix [1 0 0 1 0 0] /FirstChar 65 /LastChar 65 /Widths [1] /Encoding << /Differences [65 /a] >> /CharProcs << /a 6 0 R >> >> endobj 6 0 obj << /Length 48 >> stream 1 0 d0 BT /F1 1 Tf (AAAAAAAA) Tj ET 0 0 1 1 re f endstream endobj trailer << /Root 1 0 R >> %%EOF"; let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let mut r = PageRenderer::new(Arc::new(pdf.as_bytes().to_vec()), RenderConfig::default()); let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })); }); let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("a self-multiplying Type 3 glyph must not stall the renderer"); assert!(page.error.is_none(), "{:?}", page.error); assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws"); } /// From the nightly `cargo xtask fuzz`: many small inline images, each with "EI" (followed /// by a space) inside its data, and no text anywhere. To decide whether such an "EI" ends the /// data, the parser re-read the rest of the stream as content, and that re-read did the same /// for every inline image it met, nesting through the whole stream: 50 images (3 KB) ran for /// over ten minutes. Vendored hayro-syntax patch: the re-read doesn't nest, and the search is /// bounded. #[test] fn inline_images_with_ei_in_their_data_parse_in_linear_time() { let image = b"q 1 0 0 1 0 0 cm\nBI\n/IM true\n/W 8\n/H 2\n/BPC 1\nID \x01 EI \x02\nEI Q\n"; let content: Vec = image.iter().copied().cycle().take(image.len() * 200).collect(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let mut iter = hayro::hayro_syntax::content::TypedIter::new(&content); let mut ops = 0usize; while iter.next().is_some() { ops += 1; } let _ = tx.send(ops); }); let ops = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("inline images must not stall the parser"); assert!(ops >= 200 * 4, "every image and its q/cm/Q are read: {ops}"); } /// From the nightly `cargo xtask fuzz`: a JBIG2 image whose stream declares a page and a /// generic region of 65535 × 65535 pixels (each side within JBIG2's limit) behind a small /// /Width and /Height: decoding its 4.3 billion pixels one by one took minutes. Vendored /// hayro-jbig2 patch: at most 2^28 pixels per bitmap, and 2^26 per symbol dictionary. #[test] fn jbig2_regions_of_billions_of_pixels_are_refused() { // Embedded JBIG2 segments (ISO 14492 §7.2): page information, then an immediate generic // region, both 65535 × 65535, followed by arithmetic-coded data. let mut jbig2 = Vec::new(); let mut segment = |number: u32, kind: u8, data: &[u8]| { jbig2.extend_from_slice(&number.to_be_bytes()); jbig2.extend_from_slice(&[kind, 0, 1]); jbig2.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes()); jbig2.extend_from_slice(data); }; let side = 65535u32.to_be_bytes(); let mut page = Vec::new(); page.extend_from_slice(&side); page.extend_from_slice(&side); page.extend_from_slice(&[0; 8]); // resolution page.extend_from_slice(&[0, 0, 0]); // flags, striping segment(0, 48, &page); let mut region = Vec::new(); region.extend_from_slice(&side); region.extend_from_slice(&side); region.extend_from_slice(&[0; 9]); // x, y, combination operator region.push(0); // arithmetic coding, template 0 region.extend_from_slice(&[3, 0xff, 0xfd, 0xff, 2, 0xfe, 0xfe, 0xfe]); // AT pixels region.extend_from_slice(&[0x5a; 64]); segment(1, 38, ®ion); let content = b"q 20 0 0 20 5 5 cm /Im1 Do Q 1 0 0 rg 0 0 4 4 re f"; let mut pdf = b"%PDF-1.7\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 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n".to_vec(); pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes()); pdf.extend_from_slice(content); pdf.extend_from_slice(b"\nendstream endobj\n"); pdf.extend_from_slice( format!("5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 8 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", jbig2.len()) .as_bytes(), ); pdf.extend_from_slice(&jbig2); pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })); }); let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("a huge JBIG2 region must not stall the renderer"); assert!(page.error.is_none(), "{:?}", page.error); assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws"); } /// From the nightly `cargo xtask fuzz`: an embedded Type 1 font program holding a long run of /// integers. read-fonts 0.39 (through skrifa 0.42) looked ahead after every integer by /// parsing the next token, which looked ahead again, recursing through the whole run: a long /// run hung, and 20,000 numbers overflowed the stack and aborted the process. Fixed upstream: /// the vendored hayro-interpret now uses skrifa 0.47 (read-fonts 0.44). #[test] fn type1_font_programs_with_long_runs_of_numbers_load_quickly() { let mut font = b"%!PS-AdobeFont-1.0: Fuzz 001\n/FontMatrix [0.001 0 0 0.001 0 0] readonly def\n".to_vec(); for _ in 0..20_000 { font.extend_from_slice(b"1 "); } let content = b"BT /F1 12 Tf 5 20 Td (Hi) Tj ET 1 0 0 rg 0 0 4 4 re f"; let mut pdf = b"%PDF-1.7\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 40 40] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n".to_vec(); pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes()); pdf.extend_from_slice(content); pdf.extend_from_slice(b"\nendstream endobj\n5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Fuzz /FontDescriptor 6 0 R >> endobj\n"); pdf.extend_from_slice(b"6 0 obj << /Type /FontDescriptor /FontName /Fuzz /Flags 32 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 800 /Descent -200 /CapHeight 700 /StemV 80 /FontFile 7 0 R >> endobj\n"); pdf.extend_from_slice(format!("7 0 obj << /Length {} /Length1 {} /Length2 0 /Length3 0 >> stream\n", font.len(), font.len()).as_bytes()); pdf.extend_from_slice(&font); pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })); }); let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("a Type 1 font must not stall the renderer"); assert!(page.error.is_none(), "{:?}", page.error); assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws"); } /// From `cargo xtask fuzz`: an inline image claiming /W 4294967295 over four bytes of data /// hung in resampling (vendored hayro patch: `MAX_IMAGE_PIXELS`). #[test] fn absurd_image_dimensions_are_skipped() { let content = "q 20 0 0 20 5 5 cm BI /W 4294967295 /H 2 /BPC 8 /CS /G ID \u{0}\u{ff}\u{ff}\u{0} EI Q 1 0 0 rg 0 0 4 4 re f"; 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 40 40] /Contents 4 0 R >> endobj 4 0 obj << /Length {} >> stream {content} endstream endobj trailer << /Root 1 0 R >> %%EOF", content.len() ); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })); }); let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("an absurd image must not hang the renderer"); assert!(page.error.is_none(), "{:?}", page.error); // The rest of the page still draws: the red square at the bottom left (y-down: last rows). assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255]); } /// From `cargo xtask fuzz`: a CID font whose /W range spans every u32 inserted billions of /// widths (vendored hayro-interpret patch: `MAX_CID`). Must finish quickly. #[test] fn huge_cid_width_ranges_terminate() { let pdf = "%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 100 40] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj 4 0 obj << /Length 35 >> stream BT /F1 12 Tf 10 10 Td <0041> Tj ET endstream endobj 5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /Helvetica /Encoding /Identity-H /DescendantFonts [6 0 R] >> endobj 6 0 obj << /Type /Font /Subtype /CIDFontType2 /BaseFont /Helvetica /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor 7 0 R /W [0 4294967295 500] /W2 [0 4294967295 -1000 250 880] >> endobj 7 0 obj << /Type /FontDescriptor /FontName /Helvetica /Flags 32 /FontBBox [0 -200 1000 900] /ItalicAngle 0 /Ascent 800 /Descent -200 /CapHeight 700 /StemV 80 >> endobj trailer << /Root 1 0 R >> %%EOF"; let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let mut r = PageRenderer::new(Arc::new(pdf.as_bytes().to_vec()), RenderConfig::default()); let px = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }); let text = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 }); let _ = tx.send((px.error, text.error)); }); let (px, text) = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("rendering a huge /W range must not hang"); assert!(px.is_none() && text.is_none(), "{px:?} {text:?}"); } #[test] fn tiles_match_full_render() { let mut r = PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default()); let full = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0 }); let tile = Tile { x: 30, y: 40, w: 50, h: 30 }; let part = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: Some(tile), scale: 2.0, tag: 0 }); assert_eq!((part.width, part.height), (50, 30)); for y in 0..30u32 { for x in 0..50u32 { let a = &full.rgba[(((y + 40) * full.width + x + 30) * 4) as usize..][..4]; let b = &part.rgba[((y * 50 + x) * 4) as usize..][..4]; assert_eq!(a, b, "pixel {x},{y}"); } } } /// An alpha soft mask whose transparency group has no /CS (as Chrome writes gradient text) /// must still mask. Regression test for the vendored hayro-interpret patch. #[test] fn soft_mask_without_group_colour_space_is_applied() { let pdf = b"%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 100 100] /Contents 4 0 R /Resources << /ExtGState << /M 5 0 R >> >> >> endobj 4 0 obj << /Length 31 >> stream /M gs 1 0 0 rg 0 0 100 100 re f endstream endobj 5 0 obj << /Type /ExtGState /SMask << /Type /Mask /S /Alpha /G 6 0 R >> >> endobj 6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Length 20 >> stream 0 g 0 0 50 100 re f endstream endobj trailer << /Root 1 0 R >> %%EOF"; let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default()); let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }); let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); assert_eq!(px(25, 50), vec![255, 0, 0, 255], "inside the mask"); assert_eq!(px(75, 50), vec![255, 255, 255, 255], "outside the mask"); } }