diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index f8861143c4..351cc67c3a 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -340,7 +340,7 @@ fn text(args: &[String]) -> Result<(), CliError> { }; let mut failed: Vec = Vec::new(); for (n, p) in pages.iter().enumerate() { - let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 }); + let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); if let Some(e) = out.error { let _ = writeln!(std::io::stderr().lock(), "page {}: {e}", p + 1); failed.push(p + 1); @@ -503,7 +503,7 @@ fn render(args: &[String]) -> Result<(), CliError> { read(path)?, RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() }, ); - let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 }); + let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); if let Some(e) = p.error { return Err(e.into()); } @@ -541,8 +541,8 @@ fn check_one(args: &[String]) -> Result<(), CliError> { let mut r = PageRenderer::new(bytes, RenderConfig::default()); let mut failed = Vec::new(); for i in 0..info.pages.len().min(500) { - let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 }); - if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 }).error { + let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); + if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }).error { failed.push(format!("p{} text: {e}", i + 1)); } if let Some(e) = p.error { diff --git a/crates/automation/src/conventions.rs b/crates/automation/src/conventions.rs index 62153082ca..af8cf671ac 100644 --- a/crates/automation/src/conventions.rs +++ b/crates/automation/src/conventions.rs @@ -114,7 +114,7 @@ impl Automation { return Err(failed("page has invalid dimensions")); } let scale = (max as f32 / side).min(1.0); - let out = self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0 }); + let out = self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }); if let Some(error) = out.error { return Err(failed(error)); } diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 71602908b1..ab5d9ee1e5 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1544,7 +1544,14 @@ impl Automation { if !(1.0..=MAX_DPI).contains(&dpi) { return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}"))); } - let out = self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: (dpi / 72.0) as f32, tag: 0 }); + let out = self.renderer(id)?.render(RenderRequest { + page, + kind: RequestKind::Pixels, + tile: None, + scale: (dpi / 72.0) as f32, + tag: 0, + preview: false, + }); if let Some(e) = out.error { return Err(failed(format!("page {} could not be rendered: {e}", page + 1))); } @@ -1994,7 +2001,7 @@ fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result> /// Extract the text of `pages` (0-based) with one renderer per worker thread. fn extract_parallel(bytes: &Arc>, password: Option>, pages: &[usize]) -> Vec<(usize, std::result::Result)> { let extract = |r: &mut PageRenderer, p: usize| { - let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 }); + let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); match out.error { Some(e) => Err(e), None => Ok(out.text.map(|t| (*t).clone()).unwrap_or_default()), diff --git a/crates/engine/tests/form_rotation.rs b/crates/engine/tests/form_rotation.rs index a125af7eda..67d560bb4b 100644 --- a/crates/engine/tests/form_rotation.rs +++ b/crates/engine/tests/form_rotation.rs @@ -27,7 +27,7 @@ fn one_page() -> Document { fn ink_span(doc: &Document) -> (u32, u32) { let bytes = write_incremental(doc, &SaveOptions::default()).unwrap(); let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); - let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0 }); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, preview: false }); assert!(p.error.is_none(), "{:?}", p.error); let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0); let mut ink = false; diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index f05a811290..abb4208685 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -156,6 +156,10 @@ pub struct RenderRequest { pub scale: f32, /// Caller-defined tag, echoed back (used for zoom generations / thumbnail vs page). pub tag: u64, + /// Low-detail render for previews (page backdrops, thumbnails): images covering only a + /// few device pixels are skipped. Never set it for a final render (tiles, whole pages, + /// print, export): previews may differ by those pixels. + pub preview: bool, } /// What a render that panicked or overran the watchdog is remembered by: its page and kind. Not @@ -331,7 +335,10 @@ 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); let mut pixels = Pixels::zeroed(w.into(), h.into()).ok_or_else(|| format!("page {} is too large to render", req.page + 1))?; - render_into(page, cache, settings, &rs, pixels.bytes_mut()); + // Previews render at low detail (tiny images are skipped); finals always draw all. + let mut preview_settings = settings.clone(); + preview_settings.preview = req.preview; + render_into(page, cache, &preview_settings, &rs, pixels.bytes_mut()); Ok((w.into(), h.into(), pixels, None)) })); match result { @@ -536,6 +543,8 @@ fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { #[cfg(not(target_arch = "wasm32"))] fn same_request(a: RenderRequest, b: RenderRequest) -> bool { + // `preview` is excluded on purpose: it names the same work for cancellation, only the + // detail level of the result differs. a.page == b.page && a.kind == b.kind && a.tile == b.tile && a.scale.to_bits() == b.scale.to_bits() && a.tag == b.tag } @@ -1889,7 +1898,7 @@ mod tests { ) .into_bytes(); let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); - let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); assert!(page.error.is_none(), "{:?}", page.error); assert_eq!((page.width, page.height), (20, 20)); let (pixels, remainder) = page.rgba.as_chunks::<4>(); @@ -1907,7 +1916,7 @@ mod tests { pool.set_stuck_after(std::time::Duration::ZERO); let gate = render_gate(0, false); *lock(&pool.shared.render_gates) = vec![gate.clone()]; - let req = |page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0 }; + let req = |page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0, ..Default::default() }; pool.set_queue(vec![req(0)]); gate.entered.wait(); let first = pool.try_recv().expect("the watchdog answers while the render is held"); @@ -1934,7 +1943,7 @@ mod tests { let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 2, RenderConfig::default()); let gate = render_gate(0, true); *lock(&pool.shared.render_gates) = vec![gate.clone()]; - let req = |page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0 }; + let req = |page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0, ..Default::default() }; pool.set_queue(vec![req(0)]); gate.entered.wait(); // What the canvas sends next: the page it is still waiting for, then another one. @@ -2056,8 +2065,9 @@ mod tests { } pdf += "trailer << /Root 1 0 R >>\n%%EOF"; let pool = RenderPool::new(Arc::new(pdf.into_bytes()), 3, RenderConfig::default()); - let wanted: Vec = - (0..pages).map(|page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 2000 }).collect(); + let wanted: Vec = (0..pages) + .map(|page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 2000, ..Default::default() }) + .collect(); // The canvas's loop: take what arrived, then queue every page still missing whenever // that list changes (requests already rendering included). let mut got = vec![0; pages]; @@ -2460,7 +2470,7 @@ trailer << /Root 1 0 R >> #[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 }]); + pool.set_queue(vec![RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 7, ..Default::default() }]); let page = loop { if let Some(p) = pool.try_recv() { break p; @@ -2517,7 +2527,7 @@ trailer << /Root 1 0 R >> #[test] fn pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over() { let mut r = PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default()); - let out = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0 }); + let out = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, ..Default::default() }); assert!(out.error.is_none(), "{:?}", out.error); // The same bytes as hayro's own render to a pixmap, with the same settings. let pdf = Pdf::new(Arc::new(ONE_PAGE.to_vec())).expect("parses"); @@ -2551,10 +2561,10 @@ trailer << /Root 1 0 R >> #[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()); + assert!(r.render(RenderRequest { page: 9, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }).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()); + assert!(g.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }).error.is_some()); } #[test] @@ -2598,7 +2608,7 @@ 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 req = RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }; let p = r.render(req); assert!(p.error.is_none(), "{:?}", p.error); assert_eq!((p.width, p.height), (596, 842)); @@ -2641,7 +2651,7 @@ 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 p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); 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"); @@ -2676,7 +2686,7 @@ 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 p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); 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"); @@ -2756,7 +2766,7 @@ trailer << /Root 1 0 R >> }; let render = |bytes: Vec| { let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); - r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }) + r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }) }; for (case, form) in [ ("zero-width /BBox", "/BBox [0 0 0 20]"), @@ -2808,7 +2818,7 @@ trailer << /Root 1 0 R >> content.len() ); let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); - let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); assert!(p.error.is_none(), "{form}: {:?}", p.error); // The bounding box of everything drawn, in device pixels. let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0); @@ -2861,7 +2871,7 @@ trailer << /Root 1 0 R >> content.len() ); let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), config); - let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); assert!(p.error.is_none(), "{annot}: {:?}", p.error); p }; @@ -2932,7 +2942,7 @@ 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 }) + r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }) }; let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); let shown = render(false); @@ -2948,8 +2958,8 @@ trailer << /Root 1 0 R >> 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 }, + RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 1, ..Default::default() }, + RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 2, ..Default::default() }, ]); 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))); @@ -2998,7 +3008,7 @@ trailer << /Root 1 0 R >> pool.set_stuck_after(std::time::Duration::ZERO); let gate = render_gate(0, false); *lock(&pool.shared.render_gates) = vec![gate.clone()]; - pool.set_queue(vec![RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0 }]); + pool.set_queue(vec![RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0, ..Default::default() }]); gate.entered.wait(); let first = pool.try_recv().expect("the watchdog answers for the held render"); assert!(first.error.as_deref().is_some_and(|e| e.contains("took longer")), "{:?}", first.error); @@ -3023,7 +3033,7 @@ endstream 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 }); + let out = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, ..Default::default() }); assert!(out.error.is_none(), "{:?}", out.error); let t = out.text.expect("text"); assert_eq!(t.plain_text(), "Hello World\nSecond"); @@ -3047,7 +3057,7 @@ 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 }); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0, ..Default::default() }); p.rgba[..4].to_vec() }; assert_eq!(px(RenderConfig::default()), vec![255, 0, 0, 255], "layer is on by default"); @@ -3079,7 +3089,7 @@ trailer << /Root 1 0 R >> }; 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 }) + r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }) }; let looped = render(pdf("/Pattern cs /P1 scn 0 0 10 10 re f")); assert_eq!((looped.width, looped.height), (40, 40)); @@ -3158,7 +3168,7 @@ trailer << /Root 1 0 R >> }; 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 }) + r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }) }; 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)); @@ -3197,7 +3207,7 @@ trailer << /Root 1 0 R >> 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 }); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); assert!(page.error.is_none(), "{:?}", page.error); assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255]); } @@ -3289,7 +3299,7 @@ trailer << /Root 1 0 R >> fn jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering() { for bpc in [8, 32] { let mut renderer = PageRenderer::new(Arc::new(jpx_midpoint_pdf(bpc, 1)), RenderConfig::default()); - let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); assert!(page.error.is_none(), "{bpc} bits: {:?}", page.error); assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "other page content draws"); if bpc == 8 { @@ -3327,7 +3337,7 @@ trailer << /Root 1 0 R >> 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 _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })); }); 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); @@ -3366,7 +3376,7 @@ trailer << /Root 1 0 R >> 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 _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })); }); let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("must not stall the renderer"); assert!(page.error.is_none(), "{:?}", page.error); @@ -3404,7 +3414,7 @@ trailer << /Root 1 0 R >> 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 _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })); }); let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("must not stall the renderer"); assert!(page.error.is_none(), "{:?}", page.error); @@ -3509,7 +3519,7 @@ trailer << /Root 1 0 R >> 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 _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })); }); 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); @@ -3799,7 +3809,7 @@ trailer << /Root 1 0 R >> 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 _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })); }); 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); @@ -3860,7 +3870,7 @@ trailer << /Root 1 0 R >> 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 _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })); }); let page = rx .recv_timeout(std::time::Duration::from_secs(20)) @@ -3902,7 +3912,7 @@ trailer << /Root 1 0 R >> 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 _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false })); }); let page = rx.recv_timeout(std::time::Duration::from_secs(120)).expect("a large page must render"); assert!(page.error.is_none(), "{:?}", page.error); @@ -3921,7 +3931,7 @@ trailer << /Root 1 0 R >> 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 _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false })); }); let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("an inline image must not stall the renderer"); assert!(page.error.is_none(), "{:?}", page.error); @@ -3953,7 +3963,7 @@ trailer << /Root 1 0 R >> 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 _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false })); }); let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a content bomb must not stall the renderer"); assert!(page.error.is_none(), "{:?}", page.error); @@ -4217,7 +4227,7 @@ trailer << /Root 1 0 R >> 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 _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })); }); let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a self-painting form must not stall the renderer"); assert!(page.error.is_none(), "{:?}", page.error); @@ -4249,7 +4259,7 @@ trailer << /Root 1 0 R >> pdf.extend_from_slice(&form); pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); - let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); assert!(page.error.is_none(), "{:?}", page.error); // The last paint is at user (30, 30)–(34, 34): device row 40 - 32 = 8. assert_eq!(&page.rgba[((8 * 40 + 32) * 4)..][..4], &[255, 0, 0, 255], "the 300th paint is drawn"); @@ -4277,7 +4287,7 @@ trailer << /Root 1 0 R >> 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 _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })); }); let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a sampled-function bomb must not stall the renderer"); assert!(page.error.is_none(), "{:?}", page.error); @@ -4302,7 +4312,7 @@ trailer << /Root 1 0 R >> 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 _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })); }); let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a mesh shading must not stall the renderer"); assert!(page.error.is_none(), "{:?}", page.error); @@ -4370,7 +4380,7 @@ trailer << /Root 1 0 R >> ", ); let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); - let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); assert!(page.error.is_none(), "{:?}", page.error); let middle = &page.rgba[((28 * 40 + 12) * 4)..][..4]; assert!(middle != [255, 255, 255, 255] && middle[3] == 255, "a small patch is shaded: {middle:?}"); @@ -4419,7 +4429,7 @@ trailer << /Root 1 0 R >> 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: 25.0, tag: 0 })); + let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 25.0, tag: 0, ..Default::default() })); }); let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("overlapping mesh triangles must not stall the renderer"); assert!(page.error.is_none(), "{:?}", page.error); @@ -4447,7 +4457,7 @@ trailer << /Root 1 0 R >> 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 _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })); }); let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a mesh shading must not stall the renderer"); assert!(page.error.is_none(), "{:?}", page.error); @@ -4494,7 +4504,7 @@ trailer << /Root 1 0 R >> 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 _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })); }); 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); @@ -4521,7 +4531,7 @@ trailer << /Root 1 0 R >> 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 _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })); }); 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); @@ -4723,8 +4733,8 @@ trailer << /Root 1 0 R >> 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 px = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + let text = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, ..Default::default() }); 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"); @@ -4764,7 +4774,7 @@ trailer << /Root 1 0 R >> fn malformed_simple_font_widths_keep_later_widths_and_the_font() { let render = |widths: &str| { let mut r = PageRenderer::new(Arc::new(type3_boxes(widths)), RenderConfig::default()); - let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); assert!(p.error.is_none(), "{widths}: {:?}", p.error); p }; @@ -4795,8 +4805,8 @@ trailer << /Root 1 0 R >> let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let mut r = PageRenderer::new(Arc::new(type3_boxes("/FirstChar 4294967295 /LastChar 4294967295 /Widths [600]")), 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 px = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + let text = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, ..Default::default() }); let _ = tx.send((px, text.error)); }); let (px, text) = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("a huge /FirstChar must not exhaust memory"); @@ -4831,7 +4841,7 @@ trailer << /Root 1 0 R >> }; let render = |bytes: Vec, scale: f32| { let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); - let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale, tag: 0 }); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale, tag: 0, ..Default::default() }); assert!(p.error.is_none(), "{:?}", p.error); p }; @@ -4877,9 +4887,9 @@ trailer << /Root 1 0 R >> #[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 full = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, ..Default::default() }); 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 }); + let part = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: Some(tile), scale: 2.0, tag: 0, ..Default::default() }); assert_eq!((part.width, part.height), (50, 30)); for y in 0..30u32 { for x in 0..50u32 { @@ -4943,6 +4953,9 @@ trailer << /Root 1 0 R >> /// what it was without skipping, where paint reaches past a path into the next tile: round /// and square caps, a sharp miter, a rotated and a stretched matrix, a hairline, dashes, a /// curve, text, and a page shown rotated. + /// + /// Images are skipped the same way (vendored patch (10)): an image on a tile, one crossing + /// tile lines, and one fully off the page. #[test] fn skipping_paths_off_the_canvas_changes_no_pixel() { // Tiles of 80 device pixels at scale 2 meet every 40 pt; each mark crosses such a line @@ -4957,20 +4970,29 @@ q 0.7071 0.7071 -0.7071 0.7071 160 20 cm 15 w 1 J 0 0 m 20 0 l S Q 0 1 0 RG 3 w 10 200 m 10 290 70 290 70 200 c S BT /F1 30 Tf 70 205 Td (Hg) Tj ET q 4 0 0 0.25 0 0 cm 12 w 1 J 2 500 m 8 500 l S Q -300 300 m 320 320 l S"; +300 300 m 320 320 l S +q 10 0 0 10 5 5 cm /Im0 Do Q +q 30 0 0 30 100 100 cm /Im0 Do Q +q 5 0 0 5 500 500 cm /Im0 Do Q"; + // A 4x4 RGB image of plain `A` bytes (no `endstream` inside, so the raw stream parses). + let pixels = "A".repeat(4 * 4 * 3); for rotate in [0, 90] { let pdf = format!( "%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 240 240] /Rotate {rotate} /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 240 240] /Rotate {rotate} /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> >> endobj 4 0 obj << /Length {} >> stream {content} endstream endobj 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj +6 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length {} >> stream +{pixels} +endstream endobj trailer << /Root 1 0 R >> %%EOF", - content.len() + content.len(), + pixels.len() ); let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); let mut both = |tile| { @@ -4991,6 +5013,125 @@ trailer << /Root 1 0 R >> } } + /// Previews skip images that would cover only a few device pixels (vendored patch (11)): + /// a tiny draw is missing from the preview but present in the final, while a large draw + /// is in both. Finals never set the flag, so they draw everything. + #[test] + fn preview_renders_skip_tiny_images_but_finals_draw_everything() { + let content = "q 50 0 0 50 100 100 cm /Im0 Do Q +q 0.5 0 0 0.5 10 10 cm /Im0 Do Q"; + let pixels = "A".repeat(4 * 4 * 3); + let pdf = format!( + "%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 240 240] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length {} >> stream +{pixels} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + pixels.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let mut render = |preview| r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview }); + let first = render(false); + let second = render(false); + assert!(first.error.is_none(), "{:?}", first.error); + assert_eq!(first.rgba, second.rgba, "finals are deterministic"); + let gray = first.rgba.as_chunks::<4>().0.iter().filter(|px| px[0] == 0x41 && px[1] == 0x41 && px[2] == 0x41).count(); + assert!(gray > 1000, "the large image draws {gray} pixels"); + let preview = render(true); + assert!(preview.error.is_none(), "{:?}", preview.error); + assert_ne!(preview.rgba, first.rgba, "the tiny image is skipped only in the preview"); + } + + /// A page whose stream objects 5, 6, … are `objects` (dictionary, data), drawn by `content` + /// with `resources`, on a 130 × 70 pt page. + fn image_cache_pdf(content: &str, resources: &str, objects: &[(&str, &[u8])]) -> Vec { + let mut pdf = format!( + "%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 130 70] /Contents 4 0 R /Resources {resources} >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +", + content.len() + ) + .into_bytes(); + for (i, (dict, data)) in objects.iter().enumerate() { + pdf.extend(format!("{} 0 obj {dict} stream\n", i + 5).bytes()); + pdf.extend_from_slice(data); + pdf.extend_from_slice(b"\nendstream endobj\n"); + } + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF"); + pdf + } + + /// The RGB of the pixel at (`x`, `y`) (from the top left) of a `width`-pixel-wide RGBA raster. + fn rgb_at(rgba: &[u8], width: usize, x: usize, y: usize) -> [u8; 3] { + let i = (y * width + x) * 4; + [rgba[i], rgba[i + 1], rgba[i + 2]] + } + + const ONE_PIXEL_RGB: &str = "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 3 >>"; + + /// Two images whose dictionaries are byte for byte the same (uncompressed, one size) but + /// whose pixels differ each draw their own: the decoded-image cache tells them apart by the + /// stream, not by the dictionary's bytes alone, in one render and across renders sharing a + /// cache, and previews never hand their entries to a final render. + #[test] + fn decoded_images_with_identical_dictionaries_keep_their_own_pixels() { + let pdf = image_cache_pdf( + // B over A at the same place and size (so one decode target): B must show. A alone + // on the right, at a size of its own. + "q 50 0 0 50 10 10 cm /A Do Q q 50 0 0 50 10 10 cm /B Do Q q 40 0 0 40 80 15 cm /A Do Q", + "<< /XObject << /A 5 0 R /B 6 0 R >> >>", + &[(ONE_PIXEL_RGB, b"\xff\x00\x00"), (ONE_PIXEL_RGB, b"\x00\x00\xff")], + ); + let req = |preview| RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview }; + let mut r = PageRenderer::new(Arc::new(pdf.clone()), RenderConfig::default()); + let out = r.render(req(false)); + assert!(out.error.is_none(), "{:?}", out.error); + let w = out.width as usize; + assert_eq!(rgb_at(&out.rgba, w, 35, 35), [0, 0, 255], "the second image drew the first one's pixels"); + assert_eq!(rgb_at(&out.rgba, w, 100, 35), [255, 0, 0]); + // One cache across a preview and then a final render, as a render worker keeps it. + let pdf = parse(&Arc::new(pdf), None).expect("parses"); + let cache = RenderCache::new(); + let settings = InterpreterSettings::default(); + let preview = render_page(&pdf, &cache, &settings, false, req(true)).expect("preview renders"); + let fin = render_page(&pdf, &cache, &settings, false, req(false)).expect("final renders"); + let fresh = render_page(&pdf, &RenderCache::new(), &settings, false, req(false)).expect("final renders"); + assert_eq!(rgb_at(&preview.2, w, 35, 35), [0, 0, 255]); + assert_eq!(&fin.2[..], &fresh.2[..], "a final render after a preview matches one with a fresh cache"); + } + + /// An image drawn under a transfer function (`/TR`) is decoded with it, even when the same + /// image was drawn without one just before (and the other way round). + #[test] + fn decoded_images_under_a_transfer_function_are_not_shared() { + let pdf = image_cache_pdf( + // The image, then the same image at the same place under the inverting function + // (one decode target), then the image alone on the right. + "q 50 0 0 50 10 10 cm /A Do Q q /Inv gs 50 0 0 50 10 10 cm /A Do Q q 40 0 0 40 80 15 cm /A Do Q", + "<< /XObject << /A 5 0 R >> /ExtGState << /Inv << /TR 6 0 R >> >> >>", + &[(ONE_PIXEL_RGB, b"\xff\x00\x00"), ("<< /FunctionType 4 /Domain [0 1] /Range [0 1] /Length 14 >>", b"{ 1 exch sub }")], + ); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let out = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(out.error.is_none(), "{:?}", out.error); + let w = out.width as usize; + assert_eq!(rgb_at(&out.rgba, w, 35, 35), [0, 255, 255], "the transfer function was not applied to a cached image"); + assert_eq!(rgb_at(&out.rgba, w, 100, 35), [255, 0, 0]); + } + /// A Japanese CID font that isn't embedded (Adobe-Japan1, as `HeiseiMin-W3` with /// `UniJIS-UCS2-H` in #260's test file) drew nothing: hayro's substitutes for fonts that /// aren't embedded are Latin-only. With craft-fonts (the build input release builds embed), @@ -5018,7 +5159,7 @@ trailer << /Root 1 0 R >> }; for base_font in ["HeiseiMin-W3", "HeiseiKakuGo-W5"] { let mut r = PageRenderer::new(Arc::new(pdf(base_font).into_bytes()), RenderConfig::default()); - let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); assert!(p.error.is_none(), "{base_font}: {:?}", p.error); let inked = p.rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count(); if pdfcraft_fonts::document_japanese_font().is_none() { @@ -5082,7 +5223,7 @@ trailer << /Root 1 0 R >> content.len() ); let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); - let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); assert!(p.error.is_none(), "{base_font} <{code}>: {:?}", p.error); p.rgba }; @@ -5156,7 +5297,7 @@ trailer << /Root 1 0 R >> cmap.len() ); let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); - let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); assert!(p.error.is_none(), "{base_font} <{code}>: {:?}", p.error); p.rgba }; @@ -5242,7 +5383,7 @@ endstream endobj }; let render = |bytes: Vec| { let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); - let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); assert!(p.error.is_none(), "{:?}", p.error); p }; @@ -5344,7 +5485,7 @@ 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 p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); 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"); @@ -5371,7 +5512,7 @@ trailer << /Root 1 0 R >> content.len() ); let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); - let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); assert!(p.error.is_none(), "{content}: {:?}", p.error); p }; diff --git a/crates/render/src/raster/work_queue.rs b/crates/render/src/raster/work_queue.rs index eaa0cbab48..eb1d05b452 100644 --- a/crates/render/src/raster/work_queue.rs +++ b/crates/render/src/raster/work_queue.rs @@ -19,17 +19,18 @@ struct Key { tile: Option, scale: u32, tag: u64, + preview: bool, } impl From for Key { fn from(r: RenderRequest) -> Self { - Self { page: r.page, kind: r.kind, tile: r.tile, scale: r.scale.to_bits(), tag: r.tag } + Self { page: r.page, kind: r.kind, tile: r.tile, scale: r.scale.to_bits(), tag: r.tag, preview: r.preview } } } impl Key { fn request(self) -> RenderRequest { - RenderRequest { page: self.page, kind: self.kind, tile: self.tile, scale: f32::from_bits(self.scale), tag: self.tag } + RenderRequest { page: self.page, kind: self.kind, tile: self.tile, scale: f32::from_bits(self.scale), tag: self.tag, preview: self.preview } } } diff --git a/crates/ui-egui/src/a11y_ui.rs b/crates/ui-egui/src/a11y_ui.rs index da88e8e7c6..57a7ce86ab 100644 --- a/crates/ui-egui/src/a11y_ui.rs +++ b/crates/ui-egui/src/a11y_ui.rs @@ -445,7 +445,14 @@ fn figure_picture(ctx: &egui::Context, doc: &pdfcraft_engine::Document, f: &pdfc }; let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..Default::default() }; let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); - let out = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Pixels, tile: Some(tile), scale, tag: 0 }); + let out = r.render(pdfcraft_render::RenderRequest { + page, + kind: pdfcraft_render::RequestKind::Pixels, + tile: Some(tile), + scale, + tag: 0, + preview: false, + }); if out.error.is_some() || out.width == 0 { return None; } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c5b939bff6..3306b6d335 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -29,6 +29,8 @@ const GRID_TAG: u64 = 1 << 61; const STALE_TAG: u64 = u64::MAX; /// Pages whose raster would exceed this many device pixels on a side are drawn in tiles. const TILE_THRESHOLD: f32 = 4096.0; +/// Base device-pixel side of a page tile. The actual side grows with the zoom +/// ([`tile_side`]), so a zoomed viewport is covered by a few tiles instead of many. const TILE: u32 = 1024; /// Longest side of the backdrop drawn under tiles, and of a live page preview (signature drag). pub(crate) const BASE_SIDE: f32 = 2048.0; @@ -450,6 +452,23 @@ pub struct DocView { /// Sharp renders of the pages the grid shows larger than a thumbnail: only those in view, /// at the size drawn (device pixels wide). grid_pages: HashMap, + /// Zoom-settle debounce for heavy pages: the last requested device scale, and the egui + /// time until which sharp whole-page/tile re-renders are held back. While the zoom is + /// still moving, every frame would otherwise cancel the in-flight full-page interpret + /// (seconds on image-heavy CAD plots) and start it over, so a sharp raster never + /// arrives. The stale raster stays on screen stretched meanwhile. + last_scale: f32, + sharp_hold_until: f64, + /// Pan-settle debounce for tiled pages: the scroll offset last frame, and the egui time + /// until which sharp tile re-renders are held back. Each tile costs a full page + /// interpret, so a drag would otherwise queue a storm of them (one per frame) while the + /// in-flight ones cannot be cancelled mid-operator. Older tiles and the backdrop stay + /// stretched meanwhile; fresh tiles go out once the pan settles. + last_scroll: Vec2, + pan_hold_until: f64, + /// Pages whose raster proved slow (see `SLOW_MILLIS`): only these get the zoom/pan + /// settle holds. Fast pages render straight through, with no added latency. + slow_pages: HashSet, } /// The page grid's zoom range and the step of its buttons and keys. @@ -618,6 +637,11 @@ impl DocView { grid_zoom_request: None, grid_anchor: None, grid_pages: HashMap::new(), + last_scale: 0.0, + sharp_hold_until: 0.0, + last_scroll: Vec2::ZERO, + pan_hold_until: 0.0, + slow_pages: HashSet::new(), comments: Default::default(), measure: Default::default(), forms: Default::default(), @@ -764,6 +788,7 @@ impl DocView { self.texts.clear(); self.text_failed.clear(); self.errors.clear(); + self.slow_pages.clear(); self.waiting_since.clear(); self.last_queue.clear(); self.selection = None; @@ -797,6 +822,7 @@ impl DocView { self.stale_tiles.insert(key); } } + self.slow_pages.remove(&page); self.texts.remove(&page); self.text_failed.remove(&page); self.errors.remove(&page); @@ -1316,7 +1342,15 @@ impl DocView { .filter_map(|(page, _)| { let p = info.pages.get(page)?; let scale = thumbnail_scale(p.width, p.height, density); - Some(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: THUMB_TAG | u64::from(scale.to_bits()) }) + Some(RenderRequest { + page, + kind: RequestKind::Pixels, + tile: None, + scale, + tag: THUMB_TAG | u64::from(scale.to_bits()), + // Thumbnails are previews: tiny images are skipped for them. + preview: true, + }) }) .collect() } @@ -1349,7 +1383,9 @@ impl DocView { // decide how much prefetch fits beside it. let visible = self.frame_visible.contains(&req.page); if let Some(tile) = req.tile { - let key = (req.page, req.tag, tile.x / TILE, tile.y / TILE); + // Keyed by exact device-pixel origin: the tile side varies with the zoom + // (`tile_side`), so grid indices would need the scale to decode. + let key = (req.page, req.tag, tile.x, tile.y); let bytes = rgba_bytes(tile.w as usize, tile.h as usize); if (!visible && tile_bytes.saturating_add(bytes) > TILE_BYTES) || !tiles.insert(key) { continue; @@ -1510,6 +1546,11 @@ impl DocView { self.errors.insert(r.request.page, e); return 0; } + // Remember heavy pages: only these get the zoom/pan settle holds, so fast pages + // never pay added latency. Stale entries die with the tiles on edits. + if r.millis >= SLOW_MILLIS { + self.slow_pages.insert(r.request.page); + } let page = r.request.page; let bytes = rgba_bytes(r.width as usize, r.height as usize); if bytes != r.rgba.len() || !self.admit_texture(r.request, bytes) { @@ -1518,7 +1559,7 @@ impl DocView { // The length was checked above, so the renderer's buffer becomes the texture data as is. let img = texture_image([r.width as usize, r.height as usize], r.rgba); if let Some(t) = r.request.tile { - let key = (page, r.request.tag, t.x / TILE, t.y / TILE); + let key = (page, r.request.tag, t.x, t.y); let tex = ctx.load_texture(format!("tile-{:?}-{page}-{}-{}", self.id, t.x, t.y), img, TextureOptions::LINEAR); self.tiles.insert(key, tex); self.stale_tiles.remove(&key); @@ -1559,7 +1600,7 @@ impl DocView { return true; } if let Some(t) = req.tile { - let key = (req.page, req.tag, t.x / TILE, t.y / TILE); + let key = (req.page, req.tag, t.x, t.y); self.tiles.remove(&key); let retained = self.tiles.values().fold(0usize, |n, tex| n.saturating_add(texture_bytes(tex))); visible || bytes <= TILE_BYTES.saturating_sub(retained) @@ -1670,7 +1711,7 @@ impl DocView { let tag = scale_tag(scale) | PRINT_TAG; let fresh = self.print_pages.get(&page).is_some_and(|(have, _)| *have == tag); if !fresh { - self.frame_queue.push(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag }); + self.frame_queue.push(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag, preview: false }); } } } @@ -1686,9 +1727,28 @@ fn tag_scale(tag: u64) -> f32 { (tag as f64 / 65536.0) as f32 } +/// Device-pixel side of a page tile at `scale` for a page `page_max_px` device pixels on +/// its long side. Each tile costs a full page interpret, so tiles grow with the zoom and +/// with the page: a zoomed viewport, or a giant sheet shown whole, is covered by a few +/// large tiles instead of dozens of small ones. Bounded by the render backend (tile sides +/// above 4096 are clamped there) and kept granular at ordinary zooms. +fn tile_side(scale: f32, page_max_px: u32) -> u32 { + if !scale.is_finite() || scale <= 0.0 { + return TILE; + } + let by_zoom = (TILE as f32 * (scale / 6.0)).clamp(TILE as f32, 4096.0) as u32; + // At most four tiles along the long side, so a giant sheet needs a handful of + // interprets instead of dozens. + by_zoom.max(page_max_px.div_ceil(4).min(4096)) +} + /// Tiles of earlier zooms kept for a page while its current tiles are rendered. const OLD_TILES: usize = 64; +/// A page whose raster took at least this long is heavy: zoom/pan holds apply to it, +/// so gestures stay on stretched surfaces instead of queueing full-page interprets. +const SLOW_MILLIS: u32 = 500; + /// `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 { @@ -2159,6 +2219,18 @@ 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_tag(scale); + // Hold sharp re-renders while the zoom is still moving: a new scale every frame would + // cancel the in-flight full-page interpret and restart it, so on a heavy page (a CAD + // plot with ~1000 images renders for seconds) the sharp raster never arrives and the + // view looks stuck on a soft image. The stale raster stays stretched meanwhile; the + // sharp request goes out once the zoom settles. + let now_scale = ui.input(|i| i.time); + if (scale - view.last_scale).abs() > f32::EPSILON { + view.last_scale = scale; + view.sharp_hold_until = now_scale + 0.35; + ui.ctx().request_repaint_after(std::time::Duration::from_millis(380)); + } + let sharp_held = now_scale < view.sharp_hold_until; view.current_scale_tag = tag; let hand = app.quick_tool == QuickTool::Hand; let tool = app.quick_tool; @@ -2368,6 +2440,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // (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)); + // Adaptive tile side for this zoom and page, within the GPU texture limit: + // a zoomed viewport or a giant sheet is covered by a few large tiles + // instead of many small ones, and each tile costs one full page interpret. + // Never zero (u32 division below). + let ts = tile_side(scale, dw.max(dh)).min(max_side as u32).max(64); 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))); @@ -2380,34 +2457,46 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { .tiles .iter() .filter(|((p, t, _, _), _)| *p == i && *t != tag) - .map(|((_, t, tx, ty), tex)| (tag_scale(*t), *tx, *ty, tex)) + .map(|((_, t, ox, oy), tex)| (tag_scale(*t), *ox, *oy, tex)) .collect(); older.sort_by(|a, b| (b.0 / scale).ln().abs().total_cmp(&(a.0 / scale).ln().abs())); - for (s, tx, ty, tex) in older { + for (s, ox, oy, tex) in older { let (ow, oh) = (device_pixels(pw_pt, s) as f32, device_pixels(ph_pt, s) as f32); let [w, h] = tex.size(); - let (x, y) = ((tx * TILE) as f32, (ty * TILE) as f32); + let (x, y) = (ox as f32, oy as f32); xf.paint_image(painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh); } - for ty in vy0 / TILE..=(vy1.saturating_sub(1)) / TILE { - for tx in vx0 / TILE..=(vx1.saturating_sub(1)) / TILE { - let (x, y) = (tx * TILE, ty * TILE); - let (w, h) = (TILE.min(dw.saturating_sub(x)), TILE.min(dh.saturating_sub(y))); - if w == 0 || h == 0 { - continue; - } - wanted.push((i, scale, tag, Some(Tile { x, y, w, h }))); - if let Some(tex) = view.tiles.get(&(i, tag, tx, ty)) { - let (fw, fh) = (dw as f32, dh as f32); - 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, - ); - } + // Visible tiles, nearest the viewport centre first: each tile costs a full + // page interpret, so the workers sharpen what is looked at before the edges. + let (vcx, vcy) = ((vx0 + vx1) as f32 / 2.0, (vy0 + vy1) as f32 / 2.0); + let mut coords: Vec<(u32, u32)> = Vec::new(); + for ty in vy0 / ts..=(vy1.saturating_sub(1)) / ts { + for tx in vx0 / ts..=(vx1.saturating_sub(1)) / ts { + coords.push((tx, ty)); + } + } + coords.sort_by(|a, b| { + let da = ((a.0 * ts + ts / 2) as f32 - vcx).abs() + ((a.1 * ts + ts / 2) as f32 - vcy).abs(); + let db = ((b.0 * ts + ts / 2) as f32 - vcx).abs() + ((b.1 * ts + ts / 2) as f32 - vcy).abs(); + da.total_cmp(&db) + }); + for (tx, ty) in coords { + let (x, y) = (tx * ts, ty * ts); + let (w, h) = (ts.min(dw.saturating_sub(x)), ts.min(dh.saturating_sub(y))); + if w == 0 || h == 0 { + continue; + } + wanted.push((i, scale, tag, Some(Tile { x, y, w, h }))); + if let Some(tex) = view.tiles.get(&(i, tag, x, y)) { + let (fw, fh) = (dw as f32, dh as f32); + 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, + ); } } } @@ -2904,6 +2993,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.auto_scroll.paint(ui, avail); // Collect all main-page demand, including cached surfaces, for byte admission later. + // Pan-settle debounce for tiled pages: each tile costs a full page interpret, so holding + // fresh tiles back while the scroll offset is still moving keeps a drag on the stretched + // backdrop/older tiles instead of queueing a storm of interprets (one per frame) that + // the workers cannot cancel mid-operator. Fresh tiles go out once the pan settles; + // pages with nothing on screen yet still queue. + let scroll_offset = out.state.offset; + let now_settle = ui.input(|i| i.time); + if (scroll_offset - view.last_scroll).length() > 2.0 { + view.pan_hold_until = now_settle + 0.25; + ui.ctx().request_repaint_after(std::time::Duration::from_millis(280)); + } + view.last_scroll = scroll_offset; + let pan_held = now_settle < view.pan_hold_until; let (mut wanted, visible_now) = out.inner; let cur = view.current; wanted.sort_by_key(|w| (!visible_now.contains(&w.0), w.0.abs_diff(cur), w.3.is_some())); @@ -2919,8 +3021,51 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.tiles.remove(&k); } } - let mut queue: Vec = - wanted.iter().map(|&(page, scale, tag, tile)| RenderRequest { page, kind: RequestKind::Pixels, tile, scale, tag }).collect(); + let mut queue: Vec = wanted + .iter() + .map(|&(page, wanted_scale, wanted_tag, tile)| RenderRequest { + page, + kind: RequestKind::Pixels, + tile, + scale: wanted_scale, + tag: wanted_tag, + // The low-res backdrop under tiles is a preview: tiny images are skipped for it. + // It is the whole-page request whose scale is not the current one; sharp tiles + // and whole-page finals always draw everything. + preview: tile.is_none() && (wanted_scale != scale || wanted_tag != tag), + }) + .collect(); + // While the zoom or pan is settling, hold back sharp re-renders of pages that already + // show something (a stale whole-page raster or older tiles, drawn stretched): each new + // scale would cancel the running full-page interpret and restart it from scratch, and + // each pan frame would queue a storm of full-page tile interprets. + // Pages with nothing on screen yet still queue, so panning to a fresh page draws it. + if sharp_held || pan_held { + let main_tag = tag; + queue.retain(|req| { + // Fast pages render straight through: the hold only protects heavy pages + // whose full-page interpret costs seconds. + if !view.slow_pages.contains(&req.page) { + return true; + } + if req.tile.is_some() { + let has_current_tile = view.tiles.keys().any(|(p, t, _, _)| *p == req.page && *t == req.tag); + if has_current_tile { + return false; + } + let has_any_surface = view.pages.contains_key(&req.page) || view.tiles.keys().any(|(p, _, _, _)| *p == req.page); + !has_any_surface + } else if req.tag != main_tag { + // The low-res backdrop under tiles is zoom-independent: let it through, + // it is the stretched surface the hold relies on. + true + } else { + // The stale raster stays stretched until the zoom or pan settles; a page with + // nothing on screen yet still renders. + !view.pages.contains_key(&req.page) + } + }); + } // Text layers: visible pages for selection, every page while a search is active. let need_text = |p: &usize| !view.texts.contains_key(p) && !view.text_failed.contains(p); let mut text_pages: Vec = if hand { Vec::new() } else { visible_now.iter().copied().filter(need_text).collect() }; @@ -2929,7 +3074,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let rest: Vec = (0..n).map(|k| (cur + k) % n).filter(need_text).filter(|p| !text_pages.contains(p)).collect(); text_pages.extend(rest); } - queue.extend(text_pages.into_iter().map(|page| RenderRequest { page, kind: RequestKind::Text, tile: None, scale: 1.0, tag: TEXT_TAG })); + queue.extend(text_pages.into_iter().map(|page| RenderRequest { + page, + kind: RequestKind::Text, + tile: None, + scale: 1.0, + tag: TEXT_TAG, + preview: false, + })); view.frame_queue = queue; view.frame_visible = visible_now.iter().copied().collect(); @@ -3861,7 +4013,15 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool if !thumb_ok && ui.is_rect_visible(c) { in_view.push((i, want)); if !sharp.is_some_and(|(w, _)| sharp_enough(*w, want)) && !view.errors.contains_key(&i) { - sharper.push(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: want / p.width.max(1.0), tag: GRID_TAG }); + sharper.push(RenderRequest { + page: i, + kind: RequestKind::Pixels, + tile: None, + scale: want / p.width.max(1.0), + tag: GRID_TAG, + // Grid cells are previews: tiny images are skipped for them. + preview: true, + }); } } if let Some(tex) = sharp.map(|(_, t)| t).or(thumb) { @@ -4078,7 +4238,7 @@ endstream endobj trailer << /Root 1 0 R >> %%EOF"; let mut r = pdfcraft_render::PageRenderer::new(Arc::new(pdf.to_vec()), Default::default()); - let out = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.5, tag: 0 }); + let out = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.5, tag: 0, preview: false }); assert!(out.error.is_none(), "{:?}", out.error); let size = [out.width as usize, out.height as usize]; let copied = egui::ColorImage::from_rgba_premultiplied(size, &out.rgba); @@ -4088,6 +4248,24 @@ trailer << /Root 1 0 R >> assert_eq!(img.pixels.as_ptr().cast::(), at, "the renderer's own buffer"); } + #[test] + fn tiles_stay_granular_up_close_and_cover_giant_sheets_in_a_handful() { + // Ordinary zooms keep 1024 px tiles; degenerate scales fall back to it, never zero. + assert_eq!(tile_side(1.0, 2000), TILE); + assert_eq!(tile_side(4.0, 2000), TILE); + assert_eq!(tile_side(f32::NAN, 2000), TILE); + assert_eq!(tile_side(f32::INFINITY, 2000), TILE); + assert_eq!(tile_side(0.0, 2000), TILE); + // Zoomed viewports need a few large tiles instead of many small ones. + assert_eq!(tile_side(12.0, 2000), 2048); + assert_eq!(tile_side(20.0, 2000), 3413); + assert_eq!(tile_side(100.0, 2000), 4096); + // A giant sheet at fit width (6740 px long side) is covered four tiles a side. + assert_eq!(tile_side(96.0 / 72.0, 6740), 1685); + // The backend clamps tile sides at 4096. + assert_eq!(tile_side(20.0, 100_000), 4096); + } + #[test] fn grid_zoom_is_clamped_and_sharp_renders_tolerate_a_pinch() { assert!(sharp_enough(900, 876.0) && sharp_enough(800, 876.0) && sharp_enough(1300, 876.0)); diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 9cf38e40d4..6dbe70b882 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -12,6 +12,12 @@ use crate::canvas::{DocView, PageXform}; const ACCENT: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); +/// How many faint Edit-text outlines are drawn per page per frame. CAD plots can draw +/// ~1000 tiny images on one page; stroking all of them every frame (plus a hover query +/// per box) stalls the UI. Past this, only the selected and hovered boxes are outlined: +/// hit-testing still covers every image, so nothing becomes unselectable. +const MAX_OUTLINES: usize = 250; + /// A paragraph being edited. #[derive(Clone, Debug, PartialEq)] pub struct LineEditor { @@ -283,25 +289,33 @@ pub(crate) fn image_input( ) -> bool { let boxes: Vec = images.iter().map(|im| xf.user_rect(info, page, im.rect.map(|v| v as f32))).collect(); let painter = ui.painter(); - for b in &boxes { - painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); - } - let pointer = ui.input(|i| i.pointer.hover_pos()); + let hover_pos = ui.input(|i| i.pointer.hover_pos()); let selected = view.image_selection.as_ref().filter(|s| s.page == page).map(|s| s.index).filter(|i| *i < boxes.len()); - for (i, b) in boxes.iter().enumerate() { - let selected_box = selected == Some(i); - let hovered = ui.input(|inp| inp.pointer.hover_pos()).is_some_and(|p| b.contains(p)); - let stroke = if selected_box { - Stroke::new(1.5, ACCENT) - } else if hovered { - Stroke::new(1.5, ACCENT.gamma_multiply(0.7)) + // Few images: faint outlines for all of them, as before. Many: only the selected and + // hovered boxes, so a ~1000-image CAD page stays interactive in Edit text & images. + let faint_all = boxes.len() <= MAX_OUTLINES; + if faint_all { + for b in &boxes { + painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); + } + } + let hovered_box = hover_pos.and_then(|p| boxes.iter().rposition(|b| b.contains(p))); + if !faint_all { + if let Some(i) = selected { + painter.rect_stroke(boxes[i], CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); + } + if let Some(h) = hovered_box.filter(|h| Some(*h) != selected) { + painter.rect_stroke(boxes[h], CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); + } + } + for i in selected.into_iter().chain(hovered_box).collect::>() { + let b = boxes[i]; + if selected == Some(i) { + painter.rect_filled(b, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); + painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); } else { - Stroke::new(0.75, ACCENT.gamma_multiply(0.35)) - }; - if selected_box { - painter.rect_filled(*b, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); + painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); } - painter.rect_stroke(*b, CornerRadius::ZERO, stroke, egui::StrokeKind::Outside); } // The selected image: frame, corner handles, dragging. if let Some(i) = selected { @@ -330,7 +344,7 @@ pub(crate) fn image_input( } } if let Some((start, opposite)) = view.image_selection.as_ref().and_then(|s| s.drag) - && let Some(p) = pointer + && let Some(p) = hover_pos { let preview = match opposite { // Resize from the opposite corner, keeping the aspect ratio. @@ -362,7 +376,7 @@ pub(crate) fn image_input( return true; } } - let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false }; + let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p)) else { return false }; let Some(hit) = boxes.iter().rposition(|b| b.contains(p)) else { return false }; if selected != Some(hit) { painter.rect_stroke(boxes[hit], CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); diff --git a/crates/ui-egui/src/signature_drag.rs b/crates/ui-egui/src/signature_drag.rs index fb683f1fa9..e61b3c9cde 100644 --- a/crates/ui-egui/src/signature_drag.rs +++ b/crates/ui-egui/src/signature_drag.rs @@ -66,7 +66,7 @@ impl SignatureDrag { let scale = scale.min(crate::canvas::BASE_SIDE / p.width.max(p.height).max(1.0)); let tag = (scale * 1000.0) as u64; if layers.tag != Some(tag) { - layers.renderer.set_queue(vec![RenderRequest { page, scale, tag, ..Default::default() }]); + layers.renderer.set_queue(vec![RenderRequest { page, scale, tag, preview: true, ..Default::default() }]); layers.tag = Some(tag); } if let Some(out) = layers.renderer.try_recv() diff --git a/crates/ui-egui/src/zoom_snap.rs b/crates/ui-egui/src/zoom_snap.rs index 76e6729ca5..eb2cc86727 100644 --- a/crates/ui-egui/src/zoom_snap.rs +++ b/crates/ui-egui/src/zoom_snap.rs @@ -89,7 +89,8 @@ impl PdfCraftApp { let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); // About 800 px along the longer side is plenty to find the margins. let size = doc.info.pages.get(page).map_or(792.0, |p| p.width.max(p.height)); - let out = r.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: (800.0 / size).clamp(0.1, 4.0), tag: 0 }); + let out = + r.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: (800.0 / size).clamp(0.1, 4.0), tag: 0, preview: false }); if let Some(e) = out.error { return Err(e); } @@ -132,7 +133,7 @@ impl PdfCraftApp { } let config = RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..RenderConfig::default() }; let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); - let out = r.render(RenderRequest { page, kind: RequestKind::Pixels, tile: Some(tile), scale, tag: 0 }); + let out = r.render(RenderRequest { page, kind: RequestKind::Pixels, tile: Some(tile), scale, tag: 0, preview: false }); if let Some(e) = out.error { return Err(e); } diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index e6890f9b18..d3d16e0673 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -874,7 +874,14 @@ fn compare_page_with_raster(h: &mut Harness<'static, PdfCraftApp>, pdf: &[u8], z 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 }); + let page = renderer.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + tile: None, + scale, + tag: 0, + preview: false, + }); 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); diff --git a/vendor/README.md b/vendor/README.md index 7c9e363404..e2c3aae8e2 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -7,10 +7,10 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients` +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared` -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients` | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking` | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything` | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice. | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams` diff --git a/vendor/hayro-interpret/src/cache.rs b/vendor/hayro-interpret/src/cache.rs index 01bbf46c4e..2dda2e8a7c 100644 --- a/vendor/hayro-interpret/src/cache.rs +++ b/vendor/hayro-interpret/src/cache.rs @@ -9,7 +9,6 @@ use std::sync::{Arc, Mutex}; type CacheMap = HashMap>>; #[derive(Clone)] pub(crate) struct Cache(Arc>); - impl Default for Cache { fn default() -> Self { Self::new() @@ -50,6 +49,125 @@ impl Cache { } } +/// PdfCraft patch: a byte-budgeted cache of decoded images, keyed by everything a decode reads +/// ([`DecodedImageKey`]). Image-heavy pages decode thousands of images; without this every tile +/// and every scale decodes them all again. Entries hold owned pixels, so a hit never changes +/// them; when an insert would pass the budget the whole map is dropped (a fresh epoch), which +/// only costs re-decoding, and an image larger than the whole budget is never kept. The budget +/// caps memory per interpreter cache. A poisoned lock decodes without the cache. +#[derive(Clone)] +pub(crate) struct DecodedImageCache { + inner: Arc>, + /// Whether the render this handle serves is a preview (`InterpreterSettings::preview`). + /// Part of every key, so a preview's entries are never handed to a final render. + pub(crate) preview: bool, +} + +/// PdfCraft patch: what a decoded image depends on. Two images share an entry only when all of +/// it is equal: +/// - where the stream's dictionary lies in the document's data (`dict_at`, `dict_len`): a +/// stream's data follows its dictionary, so this tells apart streams whose dictionaries are +/// byte for byte the same (two uncompressed images of one size), which `dict_hash` alone +/// would not; the data stays put for as long as the document (and so this cache) lives; +/// - the dictionary's bytes (`dict_hash`: `/Decode`, `/ColorSpace`, `/SMask`, `/Mask`, +/// `/BitsPerComponent`, filters, …) and its object (`obj`: the decryption key depends on it); +/// - whether it is decoded as a mask, a stencil mask, raster or mask output, interpolation, +/// the decode target size, and preview or final quality. +/// +/// A transfer function (graphics state `/TR`) also changes the pixels and has no cheap key, so +/// an image drawn under one is not cached; neither is an inline image, whose dictionary lives in +/// a content stream and whose colour space can be a name resolved through the resources. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct DecodedImageKey { + pub(crate) mask_output: bool, + pub(crate) dict_at: usize, + pub(crate) dict_len: usize, + pub(crate) dict_hash: u128, + pub(crate) obj: Option<(i32, i32)>, + pub(crate) is_mask: bool, + pub(crate) is_stencil_mask: bool, + pub(crate) interpolate: bool, + pub(crate) target: Option<(u32, u32)>, + pub(crate) preview: bool, +} + +#[derive(Default)] +struct DecodedImageCacheInner { + map: HashMap>>, + bytes: usize, +} + +impl DecodedImageCache { + pub(crate) fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(DecodedImageCacheInner::default())), + preview: false, + } + } + + /// The same cache, for a preview (`true`) or a final render. + pub(crate) fn for_render(&self, preview: bool) -> Self { + Self { + inner: self.inner.clone(), + preview, + } + } + + /// The decoded image for `key`, from the cache or from `f`. `None` as the key decodes + /// without the cache. + pub(crate) fn get_or_insert_with( + &self, + key: Option, + budget: usize, + size_of: impl FnOnce(&T) -> usize, + f: impl FnOnce() -> Option, + ) -> Option { + let Some(key) = key else { return f() }; + // Fast path: a hit clones the pixels without decoding. + let hit = match self.inner.lock() { + Ok(locked) => locked + .map + .get(&key) + .and_then(|val| val.as_ref()) + .and_then(|val| val.downcast_ref::().cloned()), + // A thread panicked while holding the lock: decode without the cache. + Err(_) => return f(), + }; + if let Some(hit) = hit { + return Some(hit); + } + // Miss: decode without holding the lock (decoding must never deadlock the cache). + let val = f(); + let Ok(mut locked) = self.inner.lock() else { + return val; + }; + // Another thread may have decoded it meanwhile: keep the first copy. + if let Some(existing) = locked.map.get(&key) + && let Some(existing) = existing.as_ref().and_then(|val| val.downcast_ref::()) + { + return Some(existing.clone()); + } + let size = match &val { + Some(v) => size_of(v), + None => 0, + }; + if size > budget { + return val; + } + if locked.bytes.saturating_add(size) > budget { + locked.map.clear(); + locked.bytes = 0; + } + locked.bytes = locked.bytes.saturating_add(size); + locked.map.insert( + key, + val.clone() + .map(|val| Box::new(val) as Box), + ); + val + } +} + /// A trait for objects that can generate a unique cache key. pub trait CacheKey { /// Returns the cache key for this object. diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index abf38e314c..3e83229c5f 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -1,4 +1,4 @@ -use crate::cache::{Cache, CacheKey}; +use crate::cache::{Cache, CacheKey, DecodedImageCache}; use crate::color::ColorSpace; use crate::convert::convert_transform; use crate::font::{Font, StandardFont}; @@ -120,6 +120,9 @@ pub(crate) fn take_nested_paint(depth: u32) -> bool { pub struct InterpreterCache<'a> { pub(crate) font_cache: Rc>>>>, pub(crate) object_cache: Cache, + /// PdfCraft patch: decoded images, shared by every render through this cache so a tile + /// no longer decodes its page's images again. + pub(crate) decoded_cache: DecodedImageCache, } impl<'a> Default for InterpreterCache<'a> { @@ -134,6 +137,7 @@ impl<'a> InterpreterCache<'a> { Self { font_cache: Rc::new(RefCell::new(HashMap::new())), object_cache: Cache::new(), + decoded_cache: DecodedImageCache::new(), } } } diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 5ede4d4440..5db7e8b6bd 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -115,6 +115,11 @@ pub struct InterpreterSettings { /// operator. What was drawn is then incomplete, so only set it when the output will be /// thrown away (a viewer's render whose result nobody can receive any more). pub cancelled: Option>, + /// PdfCraft patch: a low-detail render for previews (page backdrops, thumbnails). Images + /// that would cover only a few device pixels are skipped: redrawing them at full detail + /// costs a decode and a resample each for pixels nobody can see. Never set it for a final + /// render (tiles, whole pages, print, export). + pub preview: bool, } impl Default for InterpreterSettings { @@ -136,6 +141,7 @@ impl Default for InterpreterSettings { hide_comments: false, ocg_overrides: Arc::new(Vec::new()), cancelled: None, + preview: false, } } } @@ -758,12 +764,18 @@ fn interpret_window<'a>( TypedInstruction::ShapeGlyph(_) => {} TypedInstruction::XObject(x) => { let cache = context.interpreter_cache.object_cache.clone(); + // PdfCraft patch: decoded images are cached per quality (preview or final). + let decoded_cache = context + .interpreter_cache + .decoded_cache + .for_render(context.settings.preview); let transfer_function = context.get().graphics_state.transfer_function.clone(); if let Some(x_object) = resources.get_x_object(x.0).and_then(|s| { XObject::new( &s, &context.settings.warning_sink, &cache, + Some(&decoded_cache), transfer_function.clone(), ) }) { @@ -774,11 +786,13 @@ fn interpret_window<'a>( let warning_sink = context.settings.warning_sink.clone(); let transfer_function = context.get().graphics_state.transfer_function.clone(); let cache = context.interpreter_cache.object_cache.clone(); + // PdfCraft patch: inline images are not cached (see `DecodedImageKey`). if let Some(x_object) = ImageXObject::new( i.0, |name| context.get_color_space(resources, name), &warning_sink, &cache, + None, false, transfer_function, ) { diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index 86b6fa1396..68d97d5555 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -1,4 +1,4 @@ -use crate::cache::Cache; +use crate::cache::{Cache, DecodedImageCache, DecodedImageKey}; use crate::color::{ColorComponents, ColorSpace, ToRgb}; use crate::context::Context; use crate::device::Device; @@ -34,6 +34,7 @@ impl<'a> XObject<'a> { stream: &Stream<'a>, warning_sink: &WarningSinkFn, cache: &Cache, + decoded_cache: Option<&DecodedImageCache>, transfer_function: Option, ) -> Option { let dict = stream.dict(); @@ -43,6 +44,7 @@ impl<'a> XObject<'a> { |_| None, warning_sink, cache, + decoded_cache, false, transfer_function, )?)), @@ -270,6 +272,9 @@ pub(crate) struct ImageXObject<'a> { height: u32, color_space: Option, cache: Cache, + /// PdfCraft patch: decoded pixels shared across renders (see `decoded_raster`); `None` + /// for an image that is never cached (an inline image). + decoded_cache: Option, interpolate: bool, is_mask: bool, is_stencil_mask: bool, @@ -284,6 +289,7 @@ impl<'a> ImageXObject<'a> { resolve_cs: impl FnOnce(&Name<'_>) -> Option, warning_sink: &WarningSinkFn, cache: &Cache, + decoded_cache: Option<&DecodedImageCache>, mut is_mask: bool, transfer_function: Option, ) -> Option { @@ -329,6 +335,7 @@ impl<'a> ImageXObject<'a> { Some(Self { width, cache: cache.clone(), + decoded_cache: decoded_cache.cloned(), height, color_space: image_cs, warning_sink: warning_sink.clone(), @@ -345,7 +352,16 @@ impl<'a> ImageXObject<'a> { return None; } - decode_mask(self, target_dimension) + // PdfCraft patch: decode once per `DecodedImageKey`; tiles and scales share it. + let Some(cache) = &self.decoded_cache else { + return decode_mask(self, target_dimension); + }; + cache.get_or_insert_with( + self.decode_key(cache, true, target_dimension), + MAX_DECODED_IMAGE_BYTES, + decoded_mask_size, + || decode_mask(self, target_dimension), + ) } pub(crate) fn decoded_raster( @@ -356,7 +372,44 @@ impl<'a> ImageXObject<'a> { return None; } - decode_raster(self, target_dimension) + // PdfCraft patch: decode once per `DecodedImageKey`; tiles and scales share it. + let Some(cache) = &self.decoded_cache else { + return decode_raster(self, target_dimension); + }; + cache.get_or_insert_with( + self.decode_key(cache, false, target_dimension), + MAX_DECODED_IMAGE_BYTES, + decoded_raster_size, + || decode_raster(self, target_dimension), + ) + } + + /// PdfCraft patch: the cache key of decoding this image (as a mask when `mask_output`) at + /// `target_dimension`: every input of the decode (see [`DecodedImageKey`]). `None` when one + /// of them can't be keyed cheaply (a transfer function): such a decode isn't cached. + pub(crate) fn decode_key( + &self, + cache: &DecodedImageCache, + mask_output: bool, + target_dimension: Option<(u32, u32)>, + ) -> Option { + if self.transfer_function.is_some() { + return None; + } + let dict = self.stream.dict(); + let bytes = dict.data(); + Some(DecodedImageKey { + mask_output, + dict_at: bytes.as_ptr() as usize, + dict_len: bytes.len(), + dict_hash: self.stream.cache_key(), + obj: dict.obj_id().map(|id| (id.obj_number, id.gen_number)), + is_mask: self.is_mask, + is_stencil_mask: self.is_stencil_mask, + interpolate: self.interpolate, + target: target_dimension, + preview: cache.preview, + }) } pub(crate) fn width(&self) -> u32 { @@ -378,10 +431,35 @@ impl<'a> ImageXObject<'a> { } } +/// PdfCraft patch: budget for [`DecodedImageCache`] entries per interpreter cache: decoded +/// image pixels shared across tiles and scales. Past it the cache starts a fresh epoch. +const MAX_DECODED_IMAGE_BYTES: usize = 64 << 20; + +/// PdfCraft patch: pixel bytes held by a decoded mask. +fn decoded_mask_size(mask: &DecodedMask) -> usize { + mask.luma.width as usize * mask.luma.height as usize +} + +/// PdfCraft patch: pixel bytes held by a decoded raster image and its mask. +fn decoded_raster_size(raster: &DecodedRaster) -> usize { + let image = match &raster.image { + ImageData::Rgb(d) => d.width as usize * d.height as usize * 3, + ImageData::Luma(d) => d.width as usize * d.height as usize, + }; + let alpha = raster + .alpha + .as_ref() + .map(|a| a.width as usize * a.height as usize) + .unwrap_or(0); + image.saturating_add(alpha) +} + +#[derive(Clone)] pub(crate) struct DecodedMask { pub(crate) luma: LumaData, } +#[derive(Clone)] pub(crate) struct DecodedRaster { pub(crate) image: ImageData, pub(crate) alpha: Option, @@ -754,7 +832,7 @@ fn resolve_alpha( .get::>(SMASK) .or_else(|| dict.get::>(MASK)) { - let obj = ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, true, None)?; + let obj = ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, obj.decoded_cache.as_ref(), true, None)?; decode_mask(&obj, target_dimension).map(|decoded| decoded.luma) } else if let Some(color_key_mask) = dict.get::>(MASK) { @@ -814,7 +892,7 @@ fn resolve_matte( let mut matte_rgb = [0_u8; 3]; color_space.convert_f32(&matte, &mut matte_rgb, false); - let mask_obj = ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, true, None)?; + let mask_obj = ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, obj.decoded_cache.as_ref(), true, None)?; let alpha = decode_mask(&mask_obj, target_dimension)?.luma; Some((alpha, matte_rgb)) diff --git a/vendor/hayro/src/lib.rs b/vendor/hayro/src/lib.rs index 908f755016..f87d74e931 100644 --- a/vendor/hayro/src/lib.rs +++ b/vendor/hayro/src/lib.rs @@ -194,7 +194,7 @@ pub fn render_into<'a>( render_mode: RenderMode::OptimizeSpeed, }; - let mut device = Renderer::new(pix_width, pix_height, vc_settings, cache); + let mut device = Renderer::new(pix_width, pix_height, vc_settings, cache, interpreter_settings.preview); device.ctx.set_paint(render_settings.bg_color); device diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 56e4fc632b..b4ee1b6826 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -310,6 +310,10 @@ fn control_polygon_length(path: &BezPath) -> (f64, usize) { /// Fills are flattened into chords, which stay inside the outline's bounds. const DRAW_SLACK: f64 = 0.5; +/// PdfCraft patch: previews skip images below this target area in device pixels: redrawing +/// them at full detail costs a decode and a resample each for pixels nobody can see. +const PREVIEW_MIN_IMAGE_AREA: u32 = 16; + thread_local! { /// PdfCraft patch: whether [`may_paint_canvas`] may skip paths on this thread. Turned off /// only by PdfCraft's regression test, which renders each tile with and without skipping. @@ -322,6 +326,23 @@ pub fn set_skip_offscreen_paths(skip: bool) { SKIP_OFFSCREEN_PATHS.with(|s| s.set(skip)); } +/// PdfCraft patch: whether a device-space box can paint a pixel of a `width` × `height` +/// canvas. A box whose bounds miss the canvas is skipped without changing a pixel. +/// Bounds that aren't finite are drawn as before. +fn paints_canvas(device: Rect, width: u16, height: u16) -> bool { + if !SKIP_OFFSCREEN_PATHS.with(std::cell::Cell::get) { + return true; + } + let finite = [device.x0, device.y0, device.x1, device.y1] + .iter() + .all(|v| v.is_finite()); + !finite + || (device.x0 < f64::from(width) + && device.x1 > 0.0 + && device.y0 < f64::from(height) + && device.y1 > 0.0) +} + /// PdfCraft patch: whether a path drawn with `transform` can paint a pixel of a `width` × /// `height` canvas. `reach` is how far its paint extends beyond the path, in user space: 0 for a /// fill; for a stroke, half its width times the larger of its miter limit (kurbo draws a miter @@ -338,9 +359,6 @@ fn may_paint_canvas( width: u16, height: u16, ) -> bool { - if !SKIP_OFFSCREEN_PATHS.with(std::cell::Cell::get) { - return true; - } let mut points = Rect::new( f64::INFINITY, f64::INFINITY, @@ -367,24 +385,34 @@ fn may_paint_canvas( let device = transform .transform_rect_bbox(points.inflate(grow, grow)) .inflate(2.0, 2.0); - let finite = [device.x0, device.y0, device.x1, device.y1] - .iter() - .all(|v| v.is_finite()); - !finite - || (device.x0 < f64::from(width) - && device.x1 > 0.0 - && device.y0 < f64::from(height) - && device.y1 > 0.0) + paints_canvas(device, width, height) +} + +/// PdfCraft patch: whether a `rect` drawn with `transform` can paint a pixel of a `width` × +/// `height` canvas. Used for images: decoded data can only shrink against the stream-size box +/// (subsampled `scale_factors`), so the box over-approximates what is drawn and skipping it +/// never changes a pixel, while a tile of an image-heavy page no longer decodes, resamples and +/// rasterizes every image on the page. +fn may_paint_rect(rect: Rect, transform: Affine, width: u16, height: u16) -> bool { + let device = transform + .transform_rect_bbox(rect.inflate(DRAW_SLACK, DRAW_SLACK)) + .inflate(2.0, 2.0); + paints_canvas(device, width, height) } pub(crate) struct Renderer { pub(crate) ctx: RenderContext, pub(crate) inside_pattern: bool, + /// Soft masks drawn during this render. PdfCraft note: it belongs to one `Renderer` (one + /// render, every nested renderer starting empty) and so to one `preview` setting: a mask + /// drawn for a preview, which may leave out tiny images, never reaches a final render. pub(crate) soft_mask_cache: HashMap, pub(crate) outline_cache: Rc>>>, pub(crate) cur_mask: Option, pub(crate) in_type3_glyph: bool, pub(crate) scaler: Scaler, + /// PdfCraft patch: low-detail preview render (see `InterpreterSettings::preview`). + pub(crate) preview: bool, // TODO: Remove this once vello_cpu bug with non-transparent images is fixed image_transparency_stack: Vec, } @@ -402,6 +430,7 @@ impl Renderer { height: u16, settings: RenderSettings, cache: &RenderCache<'_>, + preview: bool, ) -> Self { Self { ctx: RenderContext::new_with(width, height, settings), @@ -411,6 +440,7 @@ impl Renderer { cur_mask: None, in_type3_glyph: false, scaler: Scaler::new(ResamplingFunction::CatmullRom), + preview, image_transparency_stack: Vec::new(), } } @@ -505,6 +535,7 @@ impl Renderer { cur_mask: None, in_type3_glyph: false, scaler: self.scaler, + preview: self.preview, image_transparency_stack: Vec::new(), }; let mut mask_pix = Pixmap::new(self.ctx.width(), self.ctx.height()); @@ -1044,6 +1075,7 @@ impl Renderer { outline_cache: self.outline_cache.clone(), in_type3_glyph: false, scaler: self.scaler, + preview: self.preview, image_transparency_stack: Vec::new(), }; let mut initial_transform = Affine::scale_non_uniform(xs as f64, ys as f64) @@ -1218,6 +1250,16 @@ impl Renderer { impl<'a> Device<'a> for Renderer { fn draw_image(&mut self, image: hayro_interpret::Image<'a, '_>, mut transform: Affine) { + // PdfCraft patch: see `may_paint_rect`. Checked before decoding: off-canvas images + // cost nothing, so a tile no longer decodes every image on its page. + if !may_paint_rect( + Rect::new(0.0, 0.0, f64::from(image.width()), f64::from(image.height())), + transform, + self.ctx.width(), + self.ctx.height(), + ) { + return; + } self.ctx.set_paint_transform(Affine::IDENTITY); self.ctx.set_aliasing_threshold(Some(1)); @@ -1230,6 +1272,18 @@ impl<'a> Device<'a> for Renderer { .length() .ceil() as u32; + // PdfCraft patch: previews skip images that would cover only a few device pixels. + // Final renders always draw them. (Measured as axis lengths: the `target_*` above + // map a point, so they include the translation and only suit the decode hint.) + if self.preview { + let c = transform.as_coeffs(); + let tiny_w = (f64::from(image.width()) * (c[0] * c[0] + c[1] * c[1]).sqrt()).ceil() as u32; + let tiny_h = (f64::from(image.height()) * (c[2] * c[2] + c[3] * c[3]).sqrt()).ceil() as u32; + if tiny_w.saturating_mul(tiny_h) < PREVIEW_MIN_IMAGE_AREA { + return; + } + } + match image { hayro_interpret::Image::Stencil(s) => { s.with_stencil( @@ -1315,6 +1369,7 @@ impl<'a> Device<'a> for Renderer { cur_mask: None, in_type3_glyph: false, scaler: self.scaler, + preview: self.preview, image_transparency_stack: Vec::new(), }; let mut sub_pix = Pixmap::new(width, height); @@ -1399,7 +1454,7 @@ impl<'a> Device<'a> for Renderer { self.soft_mask_cache .entry(m.cache_key()) - .or_insert_with(|| draw_soft_mask(&m, settings, width, height)) + .or_insert_with(|| draw_soft_mask(&m, settings, width, height, self.preview)) .clone() }), None, @@ -1425,7 +1480,7 @@ impl<'a> Device<'a> for Renderer { self.soft_mask_cache .entry(m.cache_key()) - .or_insert_with(|| draw_soft_mask(&m, settings, width, height)) + .or_insert_with(|| draw_soft_mask(&m, settings, width, height, self.preview)) .clone() }); if let Some(mask) = self.cur_mask.clone() { @@ -1585,7 +1640,7 @@ fn render_shading_texture( ) } -fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, height: u16) -> Mask { +fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, height: u16, preview: bool) -> Mask { let mut renderer = Renderer { ctx: RenderContext::new_with(width, height, derive_settings(&settings)), inside_pattern: false, @@ -1594,6 +1649,7 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei outline_cache: Rc::new(std::cell::RefCell::new(HashMap::new())), in_type3_glyph: false, scaler: Scaler::new(ResamplingFunction::CatmullRom), + preview, image_transparency_stack: Vec::new(), };