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,