web: harden plot preview after adversarial review

Three reviewers (security, correctness, performance) converged on:

- Download PDF died on the second click: Closure::once_into_js throws
  when invoked twice. The handler is now a plain FnMut kept for the
  overlay's lifetime.
- Rasterisation was bounded by page count, not pixels: 12 A0 sheets at
  150 DPI peaked near 1.8 GB of RGBA plus ~1.7 GB of decoded images,
  and a drawing declaring absurd paper sizes made the rasteriser
  saturate its u16 dimensions into a ~17 GB allocation that aborts the
  tab. Preview now computes a uniform DPI under a 64-Mpx total budget
  (floor 24 DPI) from the page tree before allocating, with a per-page
  4096-px edge clamp.
- Overlays stacked when previewing twice and their blob URLs were only
  revoked via Close. The overlay is now single-instance (id-tagged);
  a replacement or Close revokes every URL through a shared registry.
- emit_image resampled before probing the XObject cache, so a title
  block placed on 12 layouts ran 12 Lanczos passes to embed once.
  The cache is now probed with the target dimensions first.
- Preview downloads are named {stem}_preview.pdf instead of a constant
  plot_preview.pdf, and the page label says 'first N of M' when the
  preview is capped while the PDF keeps every page.

(cherry picked from commit 2a05479d69)
This commit is contained in:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:04 +03:00
1 parent 8cce362009
commit 112060e370
5 files changed
+215 -25

No files matched your search

+55 -12
View File
@@ -446,7 +446,7 @@ pub fn download_bytes(name: &str, bytes: &[u8]) {
/// browser hosts routinely withhold. A Download button still hands out the
/// vector PDF, Close drops the overlay. Web only.
#[cfg(target_arch = "wasm32")]
pub fn show_pdf_preview(name: &str, pdf_bytes: &[u8], page_pngs: &[Vec<u8>]) {
pub fn show_pdf_preview(name: &str, pdf_bytes: &[u8], page_pngs: &[Vec<u8>], total_pages: usize) {
use wasm_bindgen::JsCast;
let Some(window) = web_sys::window() else {
return;
@@ -454,6 +454,20 @@ pub fn show_pdf_preview(name: &str, pdf_bytes: &[u8], page_pngs: &[Vec<u8>]) {
let Some(document) = window.document() else {
return;
};
// One preview at a time: a replotted preview replaces its predecessor and
// revokes its blob URLs, so repeated previews cannot stack overlays or
// leak the images underneath.
const OVERLAY_ID: &str = "ocs-plot-preview";
if let Some(previous) = document.get_element_by_id(OVERLAY_ID) {
previous.remove();
}
for url in preview_urls()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.drain(..)
{
let _ = web_sys::Url::revoke_object_url(&url);
}
if page_pngs.is_empty() {
// Rasterisation produced nothing: deliver the PDF itself rather than
// show an empty frame.
@@ -465,6 +479,7 @@ pub fn show_pdf_preview(name: &str, pdf_bytes: &[u8], page_pngs: &[Vec<u8>]) {
return;
};
let backdrop: web_sys::HtmlElement = el.unchecked_into();
backdrop.set_id(OVERLAY_ID);
backdrop
.set_attribute(
"style",
@@ -502,6 +517,10 @@ pub fn show_pdf_preview(name: &str, pdf_bytes: &[u8], page_pngs: &[Vec<u8>]) {
download_bytes(name, pdf_bytes);
return;
}
preview_urls()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.extend(urls.iter().cloned());
// Sticky action bar so Download/Close stay reachable while pages scroll.
let bar: web_sys::HtmlElement = document
@@ -520,11 +539,19 @@ pub fn show_pdf_preview(name: &str, pdf_bytes: &[u8], page_pngs: &[Vec<u8>]) {
.unwrap_or_else(|| backdrop.clone());
if let Ok(el) = document.create_element("span") {
let label: web_sys::HtmlElement = el.unchecked_into();
label.set_inner_text(&format!(
"Plot preview — {} page{}",
urls.len(),
if urls.len() == 1 { "" } else { "s" }
));
label.set_inner_text(&if total_pages > urls.len() {
format!(
"Plot preview — first {} of {} pages",
urls.len(),
total_pages
)
} else {
format!(
"Plot preview — {} page{}",
urls.len(),
if urls.len() == 1 { "" } else { "s" }
)
});
bar.append_child(&label).ok();
}
let button = |text: &str| -> Option<web_sys::HtmlElement> {
@@ -542,18 +569,26 @@ pub fn show_pdf_preview(name: &str, pdf_bytes: &[u8], page_pngs: &[Vec<u8>]) {
if let Some(dl) = button("Download PDF") {
let name = name.to_string();
let bytes = pdf_bytes.to_vec();
let click = wasm_bindgen::closure::Closure::once_into_js(move || {
download_bytes(&name, &bytes);
});
let click: js_sys::Function = click.unchecked_into();
// A plain FnMut stays usable for every click; the one-shot variant
// throws on the second click, and users click Download twice.
let closure: wasm_bindgen::closure::Closure<dyn FnMut()> =
wasm_bindgen::closure::Closure::new(move || {
download_bytes(&name, &bytes);
});
let click: js_sys::Function = closure.as_ref().unchecked_ref::<js_sys::Function>().clone();
closure.forget();
dl.set_onclick(Some(&click));
bar.append_child(&dl).ok();
}
if let Some(close) = button("Close") {
let backdrop = backdrop.clone();
let click = wasm_bindgen::closure::Closure::once_into_js(move || {
for url in &urls {
let _ = web_sys::Url::revoke_object_url(url);
for url in preview_urls()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.drain(..)
{
let _ = web_sys::Url::revoke_object_url(&url);
}
backdrop.remove();
});
@@ -569,6 +604,14 @@ pub fn show_pdf_preview(name: &str, pdf_bytes: &[u8], page_pngs: &[Vec<u8>]) {
}
}
/// Blob URLs owned by the live preview overlay, revoked when it is closed or
/// replaced. They live outside the wasm heap and are not GC'd on their own.
#[cfg(target_arch = "wasm32")]
fn preview_urls() -> &'static std::sync::Mutex<Vec<String>> {
static URLS: std::sync::OnceLock<std::sync::Mutex<Vec<String>>> = std::sync::OnceLock::new();
URLS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
}
/// Short platform string for bug reports: OS + architecture on the desktop,
/// the browser user-agent on the web.
#[cfg(not(target_arch = "wasm32"))]