From cdaced32c226bec80772718c4327ed5970239b99 Mon Sep 17 00:00:00 2001 From: Velli20 Date: Tue, 27 Oct 2026 22:04:32 +0200 Subject: [PATCH] Upload web images at their device size and charge only real copies Uploading an image charged three times its RGBA size against the web rendering budget: a redundant Rust RGBA copy, the browser ImageData copy, and the surface. A 4032x3024 photo needed 139.5 MiB of the 128 MiB budget, so the page failed with "web rendering resource budget exceeded". RGBA pixels now go to ImageData directly, and only non-RGBA pixels are converted first. Images are box-reduced before upload to no fewer pixels than the backing pixels they cover, growing the factor only when needed to fit the budget, so any image the decoder accepts can be drawn. The existing box reduction is exposed as pdf_image::ReduceImage. Co-Authored-By: Claude Opus 5.5 --- crates/pdf-graphics-web/src/surface_pool.rs | 49 ++++++++--- crates/pdf-graphics-web/src/web_image.rs | 96 ++++++++++++++++++++- crates/pdf-image/src/image_decoder.rs | 2 +- crates/pdf-image/src/lib.rs | 1 + crates/pdf-image/src/reduced_image.rs | 16 ++-- 5 files changed, 142 insertions(+), 22 deletions(-) diff --git a/crates/pdf-graphics-web/src/surface_pool.rs b/crates/pdf-graphics-web/src/surface_pool.rs index b11ddd69..d689ff7d 100644 --- a/crates/pdf-graphics-web/src/surface_pool.rs +++ b/crates/pdf-graphics-web/src/surface_pool.rs @@ -101,6 +101,11 @@ impl SurfacePool { Ok(BudgetedImage::new(image, reservation)) } + /// Returns the configured maximum number of accounted pixel bytes. + pub(crate) fn limit(&self) -> usize { + self.budget.limit() + } + /// Uploads validated straight RGBA pixels into a budgeted browser surface. /// /// Reserves temporary storage before conversion and propagates image, budget, @@ -108,24 +113,48 @@ impl SurfacePool { pub(crate) fn upload(&self, image: &Image) -> WebResult { let [w, h] = image.dimensions().ok_or(Error::ResourceLimit)?; let size = Size::new(w, h); - byte_size(size)?; pdf_image::raster::validate_image(image)?; - // Account for the Rust conversion buffer and browser ImageData copy before - // allocating either, and retain that reservation until upload has completed. - let _scratch = self.scratch( - byte_size(size)? - .checked_mul(2) - .ok_or(Error::ResourceLimit)?, - )?; + // Account for the browser ImageData copy, and the Rust conversion buffer of + // non-RGBA pixels, before allocating either; retain it until upload completes. + let _scratch = self.scratch(staging_bytes(size, image.pixel_format)?)?; let surface = self.acquire(size)?; - let data = pdf_image::raster::rgba(image)?; - let data = web_sys::ImageData::new_with_u8_clamped_array_and_sh(Clamped(&data), w, h)?; + let data = match image.pixel_format { + PixelFormat::RGBA8888 => web_sys::ImageData::new_with_u8_clamped_array_and_sh( + Clamped(image.data.as_ref()), + w, + h, + )?, + PixelFormat::Gray8 | PixelFormat::Alpha8 => { + let rgba = pdf_image::raster::rgba(image)?; + web_sys::ImageData::new_with_u8_clamped_array_and_sh(Clamped(&rgba), w, h)? + } + }; surface.context().put_image_data(&data, 0.0, 0.0)?; Ok(surface) } } +/// Returns the accounted bytes `upload` holds at its peak for pixels of this size and +/// format: the destination surface and the staging copies made to fill it. +pub(crate) fn upload_bytes(size: Size, format: PixelFormat) -> WebResult { + byte_size(size)? + .checked_add(staging_bytes(size, format)?) + .ok_or(Error::ResourceLimit) +} + +/// Returns the bytes of the browser ImageData copy, plus the RGBA conversion buffer +/// that non-RGBA pixels need before that copy. +fn staging_bytes(size: Size, format: PixelFormat) -> WebResult { + let bytes = byte_size(size)?; + match format { + PixelFormat::RGBA8888 => Ok(bytes), + PixelFormat::Gray8 | PixelFormat::Alpha8 => { + bytes.checked_mul(2).ok_or(Error::ResourceLimit) + } + } +} + /// Returns RGBA surface storage size, rejecting empty dimensions and byte-count overflow. pub(crate) fn byte_size(size: Size) -> WebResult { let Size { diff --git a/crates/pdf-graphics-web/src/web_image.rs b/crates/pdf-graphics-web/src/web_image.rs index 9e342af6..b7e00761 100644 --- a/crates/pdf-graphics-web/src/web_image.rs +++ b/crates/pdf-graphics-web/src/web_image.rs @@ -3,11 +3,13 @@ use crate::{ error::{WebCanvasBackendError as Error, WebResult}, surface::Surface, - surface_pool::SurfacePool, + surface_pool::{SurfacePool, upload_bytes}, web_canvas_backend::{SavedContext, WebCanvasBackend}, web_paint, }; -use pdf_graphics::{BlendMode, Image, transform::Transform}; +use num_traits::ToPrimitive; +use pdf_graphics::{BlendMode, Image, PixelFormat, size::Size, transform::Transform}; +use pdf_image::ReduceImage; use web_sys::CanvasRenderingContext2d; /// Validated image placement for one target. @@ -18,6 +20,7 @@ pub(crate) struct ImageDraw<'a> { context: &'a CanvasRenderingContext2d, pool: &'a SurfacePool, image: &'a Image, + reduction: Reduction, placement: ImagePlacement, smoothing: bool, composite: &'static str, @@ -40,6 +43,7 @@ impl<'a> ImageDraw<'a> { context: &backend.context, pool: &backend.surfaces, image, + reduction: Reduction::new(image, &unit_to_backing, &backend.surfaces)?, placement: ImagePlacement::new(transform)?, smoothing: !image.replicates_pixels(&unit_to_backing), composite: web_paint::blend(mode.as_ref()), @@ -48,7 +52,16 @@ impl<'a> ImageDraw<'a> { } pub(crate) fn draw(self) -> WebResult<()> { - let surface = self.pool.upload(self.image)?; + let surface = match self.reduction.factor { + 1 => self.pool.upload(self.image)?, + factor => { + // The reduced copy stays charged until its pixels have been uploaded. + let _reduced = self + .pool + .scratch(self.reduction.reduced_bytes(self.image)?)?; + self.pool.upload(&self.image.reduced(factor)?)? + } + }; let _saved = SavedContext::new(self.context); self.context.set_global_alpha(f64::from(self.alpha)); self.context @@ -58,6 +71,83 @@ impl<'a> ImageDraw<'a> { } } +/// The whole factor an image is box-reduced by before upload. +/// +/// Uploading more pixels than the backing pixels an image covers only costs +/// storage, and Canvas downsamples without averaging, so a reduced upload is +/// both smaller and smoother. The factor never reduces a side below the backing +/// pixels it spans, unless that is needed for the upload to fit the budget. +#[derive(Clone, Copy)] +struct Reduction { + factor: usize, +} + +impl Reduction { + fn new(image: &Image, unit_to_backing: &Transform, pool: &SurfacePool) -> WebResult { + let width = f64::from(unit_to_backing.sx).hypot(f64::from(unit_to_backing.ky)); + let height = f64::from(unit_to_backing.kx).hypot(f64::from(unit_to_backing.sy)); + let mut reduction = Self { + factor: covering_factor(image.width, width).min(covering_factor(image.height, height)), + }; + let longest = image.width.max(image.height); + while reduction.peak_bytes(image)? > pool.limit() { + if reduction.factor >= longest { + return Err(Error::ResourceLimit); + } + reduction.factor = reduction + .factor + .checked_add(1) + .ok_or(Error::ResourceLimit)?; + } + Ok(reduction) + } + + /// Returns the dimensions of the image once reduced. + fn size(&self, image: &Image) -> WebResult> { + let side = |pixels: usize| { + u32::try_from(pixels.div_ceil(self.factor)).map_err(|_| Error::ResourceLimit) + }; + Ok(Size::new(side(image.width)?, side(image.height)?)) + } + + /// Returns the bytes of the reduced copy, in the image's own pixel format. + fn reduced_bytes(&self, image: &Image) -> WebResult { + let Size { width, height } = self.size(image)?; + let bytes_per_pixel = match image.pixel_format { + PixelFormat::RGBA8888 => 4, + PixelFormat::Gray8 | PixelFormat::Alpha8 => 1, + }; + usize::try_from(width) + .ok() + .zip(usize::try_from(height).ok()) + .and_then(|(width, height)| width.checked_mul(height)) + .and_then(|pixels| pixels.checked_mul(bytes_per_pixel)) + .ok_or(Error::ResourceLimit) + } + + /// Returns the accounted bytes drawing holds at its peak: the upload, and the + /// reduced copy it is made from. + fn peak_bytes(&self, image: &Image) -> WebResult { + let upload = upload_bytes(self.size(image)?, image.pixel_format)?; + match self.factor { + 1 => Ok(upload), + _ => upload + .checked_add(self.reduced_bytes(image)?) + .ok_or(Error::ResourceLimit), + } + } +} + +/// Returns the largest whole factor that leaves `pixels` source pixels at least as +/// many as the `extent` backing pixels they span, and at least one. +fn covering_factor(pixels: usize, extent: f64) -> usize { + pixels + .to_f64() + .and_then(|pixels| (pixels / extent.max(1.0)).floor().to_usize()) + .unwrap_or(1) + .max(1) +} + /// A finite mapping from the image's unit square to logical device space. struct ImagePlacement { transform: Transform, diff --git a/crates/pdf-image/src/image_decoder.rs b/crates/pdf-image/src/image_decoder.rs index edb7bd57..ce81f7c6 100644 --- a/crates/pdf-image/src/image_decoder.rs +++ b/crates/pdf-image/src/image_decoder.rs @@ -74,7 +74,7 @@ fn decode_reduced_image( return Ok(render_ready_image(reduced, None, &bands.reduced_metadata())); } let decoded_samples = DecodedSamples::decode(payload, metadata)?; - render_ready_image(decoded_samples, None, metadata).reduced(factor) + Ok(render_ready_image(decoded_samples, None, metadata).reduced(factor)?) } /// Builds a render image from decoded samples whose soft mask, if any, has the image's size. diff --git a/crates/pdf-image/src/lib.rs b/crates/pdf-image/src/lib.rs index 86d5c3d8..03809508 100644 --- a/crates/pdf-image/src/lib.rs +++ b/crates/pdf-image/src/lib.rs @@ -10,3 +10,4 @@ mod smask_in_data; pub use error::{ImageRasterError, PdfImageError}; pub use pdf_image::{InlineDataLength, InlineImageHeader, PdfImage}; +pub use reduced_image::ReduceImage; diff --git a/crates/pdf-image/src/reduced_image.rs b/crates/pdf-image/src/reduced_image.rs index 5c6318d7..1e9f04a6 100644 --- a/crates/pdf-image/src/reduced_image.rs +++ b/crates/pdf-image/src/reduced_image.rs @@ -118,31 +118,31 @@ impl BoxReduction { } /// Allocates the buffer for `height` reduced rows. - fn buffer(&self, height: usize) -> Result, PdfImageError> { + fn buffer(&self, height: usize) -> Result, ImageRasterError> { let byte_len = self .output_width() .checked_mul(height) .and_then(|pixels| pixels.checked_mul(self.layout.bytes_per_pixel())) - .ok_or(PdfImageError::Raster(ImageRasterError::ResourceLimit))?; + .ok_or(ImageRasterError::ResourceLimit)?; let mut data = Vec::new(); - data.try_reserve_exact(byte_len) - .map_err(|_| PdfImageError::Raster(ImageRasterError::ResourceLimit))?; + data.try_reserve_exact(byte_len)?; Ok(data) } } /// Reduces a whole render-ready image. -pub(crate) trait ReduceImage { - /// Returns the image reduced by `factor`. +pub trait ReduceImage { + /// Returns the image reduced by `factor`, each output pixel averaging the + /// `factor` × `factor` block of pixels it covers. /// /// # Errors /// /// Returns a resource-limit error if the reduced pixels cannot be allocated. - fn reduced(&self, factor: usize) -> Result; + fn reduced(&self, factor: usize) -> Result; } impl ReduceImage for Image { - fn reduced(&self, factor: usize) -> Result { + fn reduced(&self, factor: usize) -> Result { let reduction = BoxReduction { factor, source_width: self.width,