diff --git a/crates/pdf-image/src/error.rs b/crates/pdf-image/src/error.rs index 68ad63fa..42da6f40 100644 --- a/crates/pdf-image/src/error.rs +++ b/crates/pdf-image/src/error.rs @@ -25,6 +25,8 @@ pub enum PdfImageError { UnsupportedSMaskInData { value: i64 }, #[error("invalid soft mask XObject: /SMask must reference an image XObject")] InvalidSoftMaskXObject, + #[error("{0}")] + Raster(#[from] ImageRasterError), #[error("invalid image dimensions: width={width}, height={height}")] InvalidImageDimensions { width: usize, height: usize }, #[error( diff --git a/crates/pdf-image/src/image_decoder.rs b/crates/pdf-image/src/image_decoder.rs index 7cd27424..463020af 100644 --- a/crates/pdf-image/src/image_decoder.rs +++ b/crates/pdf-image/src/image_decoder.rs @@ -7,6 +7,7 @@ use crate::InlineImage; use crate::decoded_samples::DecodedSamples; use crate::error::PdfImageError; use crate::image_metadata::ImageMetadata; +use crate::raster; /// Parses an Image XObject from a PDF stream dictionary and data. pub fn read_xobject( @@ -78,28 +79,83 @@ fn decode_normalized_image_with_metadata( // multiplied into alpha that already describes the image. let soft_mask = soft_mask.filter(|_| !metadata.smask_in_data.overrides_soft_mask()); let decoded_samples = DecodedSamples::decode(payload, metadata)?; + let size = (metadata.size.width(), metadata.size.height()); + match soft_mask { + Some(mask) if (mask.width, mask.height) != size => { + let image = render_ready_image(decoded_samples, None, metadata); + apply_resized_soft_mask(&image, mask) + } + _ => Ok(render_ready_image(decoded_samples, soft_mask, metadata)), + } +} + +/// Builds a render image from decoded samples whose soft mask, if any, has the image's size. +fn render_ready_image( + decoded_samples: DecodedSamples, + soft_mask: Option<&Image>, + metadata: &ImageMetadata, +) -> Image { let DecodedSamples { num_color_components, image_data, is_rgba, } = decoded_samples; if is_rgba { - return Ok(image_from_rgba( + return image_from_rgba( image_data, metadata.size.width(), metadata.size.height(), soft_mask, metadata.interpolate, - )); + ); } - Ok(Image::from_decoded_samples( + Image::from_decoded_samples( image_data, metadata.size.width(), metadata.size.height(), num_color_components, soft_mask, metadata.interpolate, - )) + ) +} + +/// Most pixels an image combined with a soft mask of a different size is +/// given, unless the image alone already has more. +/// +/// A mask can be far finer than the area it is drawn on, and the combined +/// image is held in memory and uploaded for drawing at its full size. +const MAX_COMBINED_PIXELS: usize = 1 << 22; + +/// Combines an image with a soft mask of different dimensions. +/// +/// Both cover the same unit square, so they are combined at the larger width +/// and the larger height, which keeps the detail of a fine mask over a coarse +/// color image and the reverse. A size beyond [`MAX_COMBINED_PIXELS`] is +/// reduced, with the finer mask averaged down to it. +fn apply_resized_soft_mask(image: &Image, mask: &Image) -> Result { + let (width, height) = combined_size(image, mask); + Ok(Image { + data: raster::resample_with_soft_mask(image, mask, width, height)?.into(), + width, + height, + pixel_format: pdf_graphics::PixelFormat::RGBA8888, + interpolate: image.interpolate, + }) +} + +/// Returns the size an image and its differently sized soft mask are combined at. +/// +/// That is the larger width and the larger height, divided by the smallest +/// whole factor that brings the pixel count within [`MAX_COMBINED_PIXELS`] or +/// the image's own pixel count, whichever is larger. +fn combined_size(image: &Image, mask: &Image) -> (usize, usize) { + let width = image.width.max(mask.width); + let height = image.height.max(mask.height); + let limit = MAX_COMBINED_PIXELS.max(image.width.saturating_mul(image.height)); + (1..=width.max(height)) + .map(|factor| (width.div_ceil(factor), height.div_ceil(factor))) + .find(|(width, height)| width.saturating_mul(*height) <= limit) + .unwrap_or((1, 1)) } /// Builds a render image from color-space-converted RGBA and combines its alpha with `/SMask`. @@ -151,12 +207,7 @@ fn image_from_rgba( /// Incomplete RGBA pixels are ignored. The caller zips these values with output /// pixels, leaving destination alpha unchanged when the mask ends early. fn soft_mask_alphas(mask: &Image) -> impl Iterator + '_ { - crate::raster::rgba_pixels(mask).map(|[gray, _, _, alpha]| { - // Expanded Gray8 has alpha 255, so this preserves its original sample. - // RGBA masks retain integer truncation rather than luminosity weighting. - let combined = u16::from(gray).saturating_mul(u16::from(alpha)) / 255; - u8::try_from(combined).unwrap_or(u8::MAX) - }) + raster::rgba_pixels(mask).map(raster::soft_mask_coverage) } #[cfg(test)] diff --git a/crates/pdf-image/src/raster.rs b/crates/pdf-image/src/raster.rs index f39ce20d..978b927f 100644 --- a/crates/pdf-image/src/raster.rs +++ b/crates/pdf-image/src/raster.rs @@ -84,6 +84,114 @@ pub fn rgba(image: &Image) -> Result, ImageRasterError> { Ok(data) } +/// Combines an image with a soft mask of a different size into `width` × +/// `height` straight RGBA pixels, in one pass. +/// +/// Each output pixel takes its color from the image pixel it falls in and +/// multiplies that pixel's alpha by the mask coverage averaged over a 2 × 2 +/// grid of samples spread across the mask area it covers. Enlarging thus +/// replicates pixels, while shrinking a mask keeps detail finer than the +/// output, such as thin strokes, as partial coverage, at a cost bounded by the +/// output size however large the mask is. Unlike the public operations this +/// accepts the short buffers decoders may produce: pixels missing from either +/// source read as zero. +/// +/// # Errors +/// +/// Returns a resource-limit error if the output byte count overflows or its +/// allocation fails. +pub(crate) fn resample_with_soft_mask( + image: &Image, + mask: &Image, + width: usize, + height: usize, +) -> Result, ImageRasterError> { + let byte_len = width + .checked_mul(height) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or(ImageRasterError::ResourceLimit)?; + let mut data = Vec::new(); + data.try_reserve_exact(byte_len) + .map_err(|_| ImageRasterError::ResourceLimit)?; + let columns: Vec<_> = (0..width) + .map(|x| { + let [image_column, _] = sample_positions(x, image.width, width); + (image_column, sample_positions(x, mask.width, width)) + }) + .collect(); + for y in 0..height { + let [image_row, _] = sample_positions(y, image.height, height); + let image_row_start = image_row.saturating_mul(image.width); + let mask_row_starts = + sample_positions(y, mask.height, height).map(|row| row.saturating_mul(mask.width)); + for (image_column, mask_columns) in &columns { + let [r, g, b, alpha] = pixel_at(image, image_row_start.saturating_add(*image_column)); + let coverage = average_coverage(mask, mask_row_starts, mask_columns); + let alpha = u16::from(alpha).saturating_mul(u16::from(coverage)) / 255; + data.extend_from_slice(&[r, g, b, u8::try_from(alpha).unwrap_or(u8::MAX)]); + } + } + Ok(data) +} + +/// Averages the soft-mask coverage of the mask pixels at each pair of a row +/// start and a column. +fn average_coverage(mask: &Image, row_starts: [usize; 2], columns: &[usize; 2]) -> u8 { + let mut total = 0_u16; + for row_start in row_starts { + for &column in columns { + let coverage = soft_mask_coverage(pixel_at(mask, row_start.saturating_add(column))); + total = total.saturating_add(u16::from(coverage)); + } + } + u8::try_from(total / 4).unwrap_or(u8::MAX) +} + +/// Returns the coverage a straight RGBA soft-mask pixel gives. +/// +/// Expanded Gray8 has alpha 255, so this is its original sample. RGBA masks +/// keep integer truncation rather than luminosity weighting. +pub(crate) fn soft_mask_coverage([gray, _, _, alpha]: [u8; 4]) -> u8 { + let coverage = u16::from(gray).saturating_mul(u16::from(alpha)) / 255; + u8::try_from(coverage).unwrap_or(u8::MAX) +} + +/// Returns two source rows or columns spread over the span that output row or +/// column `index` covers when `source_len` of them are scaled to `len`. +/// +/// A span of one gives that one twice. +fn sample_positions(index: usize, source_len: usize, len: usize) -> [usize; 2] { + let scale = |index: usize| { + index + .saturating_mul(source_len) + .checked_div(len) + .unwrap_or(0) + }; + let start = scale(index); + let span = scale(index.saturating_add(1)).saturating_sub(start); + [span / 4, span.saturating_mul(3) / 4].map(|offset| start.saturating_add(offset)) +} + +/// Reads one pixel as straight RGBA, expanding Gray8 to opaque RGB. +/// +/// A pixel beyond the end of the buffer reads as zero. +fn pixel_at(image: &Image, index: usize) -> [u8; 4] { + match image.pixel_format { + PixelFormat::Gray8 => image + .data + .get(index) + .map_or([0; 4], |&gray| [gray, gray, gray, 255]), + PixelFormat::RGBA8888 => { + let start = index.saturating_mul(4); + image + .data + .get(start..start.saturating_add(4)) + .and_then(|pixel| <[u8; 4]>::try_from(pixel).ok()) + .unwrap_or([0; 4]) + } + } +} + /// Creates a white RGBA mask whose alpha is the source RGB luminosity. /// /// Uses straight RGB independently of source alpha, expanding Gray8 first.