Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/pdf-image/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
71 changes: 61 additions & 10 deletions crates/pdf-image/src/image_decoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<Image, PdfImageError> {
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`.
Expand Down Expand Up @@ -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<Item = u8> + '_ {
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)]
Expand Down
108 changes: 108 additions & 0 deletions crates/pdf-image/src/raster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,114 @@ pub fn rgba(image: &Image) -> Result<Vec<u8>, 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<Vec<u8>, 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.
Expand Down
Loading