Heavy pages: cull off-tile images, cache decodes, preview backdrops, calmer queues (#1117)

* Heavy pages: cull off-tile images, cache decodes, preview backdrops, calmer queues

A large CAD sheet (page 24 of the benchmark file: ~5050 pt wide, 2104
images, 9 MB of content) cost a full page interpret per tile: 4.3 s for a
1024 px tile at 1000%, 10.5 s for the whole page, and a storm of renders on
every pan frame. Tiles now cost 0.6 s, the sheet is covered by a handful of
large tiles, and gestures no longer queue work faster than workers finish it.

- Vendored hayro patch (10): skip images whose stream-size box misses the
  canvas before decoding them, so a tile no longer decodes, resamples and
  rasterizes every image on its page. Pixel-identical; the skip regression
  test now covers images too.
- Vendored hayro-interpret patch (23): decoded image pixels shared across
  renders through the interpreter cache, keyed by stream and decode target
  size within a 64 MiB budget.
- Preview renders (hayro patch 11, RenderRequest::preview through to the
  device): backdrops, thumbnails, grid cells and signature-drag backgrounds
  skip images under 16 device px. Tiles, whole pages, print and export always
  draw everything. Previews may differ by those pixels; finals don't.
- Viewer: zoom/pan settle holds apply only to pages whose raster proved slow
  (slow_pages, 500 ms), so fast pages render with no added latency; tile sides
  grow with zoom and page size (giant sheets need ~8 tiles, not ~28), queued
  center-first and keyed by exact coordinates; Edit-text outlines capped.
- vendor/README.md records the new patches with their tests.

Co-Authored-By: Muse Spark <noreply@meta.com>

* Decoded-image cache: key every decode input, survive a poisoned lock

The key was the dictionary's bytes and the target size, so two images with byte-identical
dictionaries (uncompressed, one size) shared pixels, and an image drawn under a transfer
function reused pixels decoded without it. DecodedImageKey now holds the dictionary's place
in the document (its data follows it), its bytes and object, mask/stencil/raster output,
interpolation, the target size and preview or final quality; images under a transfer
function and inline images (named colour spaces, dictionaries in content streams) are not
cached, and an image over the budget is never kept. A poisoned lock decodes uncached
instead of panicking. Soft masks: the per-renderer cache can't carry a preview mask into a
final render (documented). Regression tests for identical dictionaries, preview then final
through one cache, and transfer functions.

---------

Co-authored-by: Muse Spark <noreply@meta.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Maksim Popov
2026-10-11 17:42:48 -04:00
committed by GitHub
co-authored by Muse Spark Brandon Thomas
parent e609722b36
commit d415749556
19 changed files with 777 additions and 151 deletions
+4 -4
View File
@@ -340,7 +340,7 @@ fn text(args: &[String]) -> Result<(), CliError> {
};
let mut failed: Vec<usize> = 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 {
+1 -1
View File
@@ -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));
}
+9 -2
View File
@@ -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<Vec<u8>>
/// Extract the text of `pages` (0-based) with one renderer per worker thread.
fn extract_parallel(bytes: &Arc<Vec<u8>>, password: Option<Arc<str>>, pages: &[usize]) -> Vec<(usize, std::result::Result<PageText, String>)> {
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()),
+1 -1
View File
@@ -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;
+202 -61
View File
@@ -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<T>(m: &Mutex<T>) -> 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<RenderRequest> =
(0..pages).map(|page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 2000 }).collect();
let wanted: Vec<RenderRequest> = (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<u8>| {
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<u8>, 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<u8> {
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<u8>| {
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
};
+3 -2
View File
@@ -19,17 +19,18 @@ struct Key {
tile: Option<Tile>,
scale: u32,
tag: u64,
preview: bool,
}
impl From<RenderRequest> 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 }
}
}
+8 -1
View File
@@ -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;
}
+210 -32
View File
@@ -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<usize, (u32, TextureHandle)>,
/// 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<usize>,
}
/// 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<RenderRequest> =
wanted.iter().map(|&(page, scale, tag, tile)| RenderRequest { page, kind: RequestKind::Pixels, tile, scale, tag }).collect();
let mut queue: Vec<RenderRequest> = 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<usize> = 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<usize> = (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::<u8>(), 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));
+32 -18
View File
@@ -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<Rect> = 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::<Vec<_>>() {
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);
+1 -1
View File
@@ -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()
+3 -2
View File
@@ -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);
}
+8 -1
View File
@@ -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);
+3 -3
View File
File diff suppressed because one or more lines are too long
+119 -1
View File
@@ -9,7 +9,6 @@ use std::sync::{Arc, Mutex};
type CacheMap = HashMap<u128, Option<Box<dyn Any + Send + Sync>>>;
#[derive(Clone)]
pub(crate) struct Cache(Arc<Mutex<CacheMap>>);
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<Mutex<DecodedImageCacheInner>>,
/// 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<DecodedImageKey, Option<Box<dyn Any + Send + Sync>>>,
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<T: Clone + Send + Sync + 'static>(
&self,
key: Option<DecodedImageKey>,
budget: usize,
size_of: impl FnOnce(&T) -> usize,
f: impl FnOnce() -> Option<T>,
) -> Option<T> {
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::<T>().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::<T>())
{
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<dyn Any + Send + Sync>),
);
val
}
}
/// A trait for objects that can generate a unique cache key.
pub trait CacheKey {
/// Returns the cache key for this object.
+5 -1
View File
@@ -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<RefCell<HashMap<u128, Option<Font<'a>>>>>,
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(),
}
}
}
+14
View File
@@ -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<Arc<AtomicBool>>,
/// 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,
) {
+83 -5
View File
@@ -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<ActiveTransferFunction>,
) -> Option<Self> {
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<ColorSpace>,
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<DecodedImageCache>,
interpolate: bool,
is_mask: bool,
is_stencil_mask: bool,
@@ -284,6 +289,7 @@ impl<'a> ImageXObject<'a> {
resolve_cs: impl FnOnce(&Name<'_>) -> Option<ColorSpace>,
warning_sink: &WarningSinkFn,
cache: &Cache,
decoded_cache: Option<&DecodedImageCache>,
mut is_mask: bool,
transfer_function: Option<ActiveTransferFunction>,
) -> Option<Self> {
@@ -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<DecodedImageKey> {
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<LumaData>,
@@ -754,7 +832,7 @@ fn resolve_alpha(
.get::<Stream<'_>>(SMASK)
.or_else(|| dict.get::<Stream<'_>>(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::<SmallVec<[u16; 4]>>(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))
+1 -1
View File
@@ -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
+70 -14
View File
@@ -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<u128, Mask>,
pub(crate) outline_cache: Rc<std::cell::RefCell<HashMap<u128, Rc<BezPath>>>>,
pub(crate) cur_mask: Option<Mask>,
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<bool>,
}
@@ -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(),
};