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.
This commit is contained in:
Afonsop committed 2026-09-15 21:07:55 +01:00
1 parent 6306359390
commit 2a05479d69
5 files changed
+218 -25

No files matched your search

+10 -1
View File
@@ -305,7 +305,13 @@ fn emit_image(
// One shared factor from the denser axis preserves the pixel aspect ratio.
let (w, h) = downsample_target(width, height, dpi_x.max(dpi_y), target);
// Skip token reductions (< 5%): a Lanczos pass costs more than it saves.
if (w != width || h != height) && w as f64 / (width as f64) < 0.95 {
let needs_resample = (w != width || h != height) && w as f64 / (width as f64) < 0.95;
// The same Arc placed repeatedly (a title block on every layout)
// resamples once: an existing XObject at the target dims means
// only the draw transform below is new work.
let already_embedded =
resources.contains_key(&(std::sync::Arc::as_ptr(&image.pixels) as usize, w, h));
if needs_resample && !already_embedded {
if let Some(resized) = image::RgbaImage::from_raw(
width,
height,
@@ -319,6 +325,9 @@ fn emit_image(
height = h;
pixels = std::borrow::Cow::Owned(resized);
}
} else if already_embedded {
width = w;
height = h;
}
}
}
+19 -8
View File
@@ -336,10 +336,15 @@ pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> {
}
/// Render a page and show it the way each platform previews: the OS PDF
/// viewer on desktop, an in-page rasterised overlay on the web.
pub fn preview_page_pdf(page: &crate::io::pdf_export::PdfPageInput) -> Result<(), String> {
/// viewer on desktop, an in-page rasterised overlay on the web. `stem` names
/// the web download (`{stem}_preview.pdf`); the desktop temp file is unchanged.
pub fn preview_page_pdf(
stem: &str,
page: &crate::io::pdf_export::PdfPageInput,
) -> Result<(), String> {
#[cfg(not(target_arch = "wasm32"))]
{
let _ = stem;
let tmp = temp_pdf_path("preview");
crate::io::pdf_export::export_pdf(page, &tmp)?;
open_in_viewer(&tmp)
@@ -347,26 +352,32 @@ pub fn preview_page_pdf(page: &crate::io::pdf_export::PdfPageInput) -> Result<()
#[cfg(target_arch = "wasm32")]
{
let bytes = crate::io::pdf_export::render_pdf_bytes(std::slice::from_ref(page), None)?;
web_preview_overlay(&bytes)
web_preview_overlay(stem, &bytes)
}
}
/// Web-only multi-page variant used by print-all: render every layout into
/// one PDF and preview the first pages in the overlay.
#[cfg(target_arch = "wasm32")]
pub fn preview_pages_pdf(pages: &[crate::io::pdf_export::PdfPageInput]) -> Result<(), String> {
pub fn preview_pages_pdf(
stem: &str,
pages: &[crate::io::pdf_export::PdfPageInput],
) -> Result<(), String> {
let bytes = crate::io::pdf_export::render_pdf_bytes(pages, None)?;
web_preview_overlay(&bytes)
web_preview_overlay(stem, &bytes)
}
/// Web preview: rasterise the plotted PDF (the same hayro pipeline that draws
/// PDF underlays) and show the pages in an in-page overlay. The browser gets
/// plain PNGs, so neither popup permission nor a PDF viewer plugin is needed.
/// Rasterisation is bounded by a total pixel budget so large sheets lower the
/// preview DPI instead of the tab's memory ceiling.
#[cfg(target_arch = "wasm32")]
fn web_preview_overlay(bytes: &[u8]) -> Result<(), String> {
fn web_preview_overlay(stem: &str, bytes: &[u8]) -> Result<(), String> {
use crate::scene::model::pdf_raster;
const PREVIEW_DPI: f32 = 150.0;
const MAX_PREVIEW_PAGES: usize = 12;
let pages = crate::scene::model::pdf_raster::rasterize_bytes_pages(
let (pages, total_pages) = pdf_raster::rasterize_preview_pages(
std::sync::Arc::new(bytes.to_vec()),
PREVIEW_DPI,
MAX_PREVIEW_PAGES,
@@ -385,7 +396,7 @@ fn web_preview_overlay(bytes: &[u8]) -> Result<(), String> {
pngs.push(png);
}
}
crate::sys::show_pdf_preview("plot_preview.pdf", bytes, &pngs);
crate::sys::show_pdf_preview(&format!("{stem}_preview.pdf"), bytes, &pngs, total_pages);
Ok(())
}