From eed107a3ece6c48b2bf73f8d74a1f20a8f0d4fc7 Mon Sep 17 00:00:00 2001 From: Velli20 Date: Sun, 11 Oct 2026 14:54:49 +0300 Subject: [PATCH] Combine image soft masks of a different size at the larger resolution An /SMask may have different dimensions from its image, since both map onto the unit square. The decoder paired mask and image pixels one to one, so a low-resolution color image kept its own size and took its alpha from the first few mask samples. Images that carry glyph shapes in a full-resolution mask over a 2x2 single-color image rendered fully transparent, leaving pages without their text. Combine the two at the larger width and the larger height, capped at 4M pixels unless the image alone is larger, by dividing both axes by the smallest whole factor that fits; a mask can be far finer than the area it is drawn on. Build the result in one pass: each output pixel takes its color from the image pixel it falls in and its coverage from a 2x2 grid of mask samples over the area it covers, so the work follows the output size rather than the mask's. Co-Authored-By: Claude Opus 5.5 --- crates/pdf-image/src/error.rs | 2 + crates/pdf-image/src/image_decoder.rs | 71 ++++++++++++++--- crates/pdf-image/src/raster.rs | 108 ++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 10 deletions(-) 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.