diff --git a/crates/pdf-canvas/src/canvas_external_object_ops.rs b/crates/pdf-canvas/src/canvas_external_object_ops.rs index bc149470..8fec8cb9 100644 --- a/crates/pdf-canvas/src/canvas_external_object_ops.rs +++ b/crates/pdf-canvas/src/canvas_external_object_ops.rs @@ -8,8 +8,8 @@ //! matrix, so rotation, shear and mirroring in the CTM are all preserved. use pdf_content_stream_operators::pdf_operator_backend::XObjectOps; -use pdf_graphics::{Image, transform::Transform}; -use pdf_image::{InlineImage, decode_inline_image}; +use pdf_graphics::{Image, PixelFormat, transform::Transform}; +use pdf_image::{InlineImage, decode_inline_image, raster::stencil_image}; use pdf_resources::resource::Resource; use crate::{canvas_backend::CanvasBackend, error::PdfCanvasError, pdf_canvas::PdfCanvas}; @@ -94,11 +94,18 @@ impl PdfCanvas<'_, B> { } /// Computes image placement before entering the backend-only mask callback. + /// + /// An image mask's coverage is painted in the current non-stroking color. fn render_decoded_image( &mut self, image: &Image, inline_image: bool, ) -> Result<(), PdfCanvasError> { + if image.pixel_format == PixelFormat::Alpha8 { + let [r, g, b, _] = self.current_state()?.paint.fill_color.to_rgba8(); + let stencil = stencil_image(image, [r, g, b])?; + return self.render_decoded_image(&stencil, inline_image); + } let transform = self .current_state()? .transform diff --git a/crates/pdf-graphics-skia/src/skia_canvas_backend.rs b/crates/pdf-graphics-skia/src/skia_canvas_backend.rs index 83262c2f..b05ecd61 100644 --- a/crates/pdf-graphics-skia/src/skia_canvas_backend.rs +++ b/crates/pdf-graphics-skia/src/skia_canvas_backend.rs @@ -171,6 +171,7 @@ fn to_skia_image(image: &Image) -> Result { let color_type = match pixel_format { PixelFormat::RGBA8888 => skia_safe::ColorType::RGBA8888, PixelFormat::Gray8 => skia_safe::ColorType::Gray8, + PixelFormat::Alpha8 => skia_safe::ColorType::Alpha8, }; let image_info = skia_safe::ImageInfo::new( diff --git a/crates/pdf-graphics/src/lib.rs b/crates/pdf-graphics/src/lib.rs index 7654de7b..adc19e1e 100644 --- a/crates/pdf-graphics/src/lib.rs +++ b/crates/pdf-graphics/src/lib.rs @@ -99,4 +99,6 @@ pub enum PixelFormat { RGBA8888, /// Grayscale format with 8 bits per pixel. Gray8, + /// Coverage of an image mask with 8 bits per pixel, painted in the current fill color. + Alpha8, } diff --git a/crates/pdf-image/src/decoded_samples.rs b/crates/pdf-image/src/decoded_samples.rs index 89f5e35f..1467fb85 100644 --- a/crates/pdf-image/src/decoded_samples.rs +++ b/crates/pdf-image/src/decoded_samples.rs @@ -280,26 +280,33 @@ impl DecodedSamples { }) } - /// Applies an explicit decode map or the implicit PDF identity/inverted default. + /// Applies an explicit decode map or the implicit PDF identity default. + /// + /// An image mask paints where its decoded value is 0, so its samples are + /// returned as coverage instead: `output_max` where the mask paints. fn apply_decode( sample_codes: Bytes, decode: Option<&DecodeMap>, sample_max: u8, output_max: u8, - default_inverted: bool, + image_mask: bool, ) -> Bytes { if let Some(decode) = decode { - return decode - .apply_to_bytes(sample_codes.as_ref(), sample_max, output_max) - .into(); + let mut decoded = decode.apply_to_bytes(sample_codes.as_ref(), sample_max, output_max); + if image_mask { + decoded + .iter_mut() + .for_each(|value| *value = output_max.saturating_sub(*value)); + } + return decoded.into(); } - if sample_max == output_max && !default_inverted { + if sample_max == output_max && !image_mask { return sample_codes; } let mut decoded = BytesMut::from(sample_codes); - let default_range = if default_inverted { + let default_range = if image_mask { DecodeRange::inverted_identity() } else { DecodeRange::identity() diff --git a/crates/pdf-image/src/image_decoder.rs b/crates/pdf-image/src/image_decoder.rs index e337a524..666f2b5a 100644 --- a/crates/pdf-image/src/image_decoder.rs +++ b/crates/pdf-image/src/image_decoder.rs @@ -160,14 +160,22 @@ fn render_ready_image( metadata.interpolate, ); } - Image::from_decoded_samples( + let image = Image::from_decoded_samples( image_data, metadata.size.width(), metadata.size.height(), num_color_components, soft_mask, metadata.interpolate, - ) + ); + // An image mask's samples are coverage, painted later in the fill color. + match metadata.image_mask && image.pixel_format == pdf_graphics::PixelFormat::Gray8 { + true => Image { + pixel_format: pdf_graphics::PixelFormat::Alpha8, + ..image + }, + false => image, + } } /// Most pixels an image combined with a soft mask of a different size is @@ -770,7 +778,7 @@ mod tests { ) .expect("image masks should default missing BitsPerComponent to 1"); - assert_eq!(image.pixel_format, pdf_graphics::PixelFormat::Gray8); + assert_eq!(image.pixel_format, pdf_graphics::PixelFormat::Alpha8); assert_eq!(image.data.as_ref(), &[0x00, 0xFF, 0x00, 0xFF]); } diff --git a/crates/pdf-image/src/raster.rs b/crates/pdf-image/src/raster.rs index f095df6d..1977b022 100644 --- a/crates/pdf-image/src/raster.rs +++ b/crates/pdf-image/src/raster.rs @@ -27,7 +27,7 @@ pub fn validate_image(image: &Image) -> Result<(), ImageRasterError> { .ok_or(ImageRasterError::ResourceLimit)?; let expected = match image.pixel_format { PixelFormat::RGBA8888 => bytes, - PixelFormat::Gray8 => bytes / 4, + PixelFormat::Gray8 | PixelFormat::Alpha8 => bytes / 4, }; if image.data.len() != expected { return Err(ImageRasterError::InvalidInput("image byte length")); @@ -84,29 +84,33 @@ pub fn nearest_pixel(image: &Image, x: f32, y: f32) -> Option<[u8; 4]> { )) } -/// Iterates complete pixels as straight RGBA, expanding Gray8 with opaque alpha. +/// Iterates complete pixels as straight RGBA, expanding Gray8 with opaque alpha +/// and Alpha8 as black with its coverage as alpha. /// /// This iterator borrows the buffer without validating declared dimensions. /// Decoder soft masks intentionally accept short buffers and ignore incomplete /// trailing RGBA pixels; public raster operations validate before using it. pub(crate) fn rgba_pixels(image: &Image) -> impl Iterator + '_ { - let stride = match image.pixel_format { - PixelFormat::Gray8 => 1, + let format = image.pixel_format; + let stride = match format { + PixelFormat::Gray8 | PixelFormat::Alpha8 => 1, PixelFormat::RGBA8888 => 4, }; image .data .chunks_exact(stride) - .filter_map(|pixel| match pixel { - [gray] => Some([*gray, *gray, *gray, 255]), - [r, g, b, a] => Some([*r, *g, *b, *a]), + .filter_map(move |pixel| match (format, pixel) { + (PixelFormat::Alpha8, [alpha]) => Some([0, 0, 0, *alpha]), + (_, [gray]) => Some([*gray, *gray, *gray, 255]), + (_, [r, g, b, a]) => Some([*r, *g, *b, *a]), _ => None, }) } /// Copies an image into an owned, packed straight-alpha RGBA8 buffer. /// -/// Gray8 pixels expand to opaque RGB. Existing RGBA bytes, including color +/// Gray8 pixels expand to opaque RGB and Alpha8 pixels to black with their +/// coverage as alpha. Existing RGBA bytes, including color /// channels beneath transparent pixels, remain unchanged. /// /// # Errors @@ -118,7 +122,7 @@ pub fn rgba(image: &Image) -> Result, ImageRasterError> { let mut data = rgba_buffer(image.width, image.height)?; match image.pixel_format { PixelFormat::RGBA8888 => data.extend_from_slice(&image.data), - PixelFormat::Gray8 => { + PixelFormat::Gray8 | PixelFormat::Alpha8 => { for pixel in rgba_pixels(image) { data.extend_from_slice(&pixel); } @@ -209,7 +213,8 @@ fn sample_positions(index: usize, source_len: usize, len: usize) -> [usize; 2] { [span / 4, span.saturating_mul(3) / 4].map(|offset| start.saturating_add(offset)) } -/// Reads one pixel as straight RGBA, expanding Gray8 to opaque RGB. +/// Reads one pixel as straight RGBA, expanding Gray8 to opaque RGB and Alpha8 +/// to black with its coverage as alpha. /// /// A pixel beyond the end of the buffer reads as zero. fn pixel_at(image: &Image, index: usize) -> [u8; 4] { @@ -218,6 +223,10 @@ fn pixel_at(image: &Image, index: usize) -> [u8; 4] { .data .get(index) .map_or([0; 4], |&gray| [gray, gray, gray, 255]), + PixelFormat::Alpha8 => image + .data + .get(index) + .map_or([0; 4], |&alpha| [0, 0, 0, alpha]), PixelFormat::RGBA8888 => { let start = index.saturating_mul(4); image @@ -239,7 +248,7 @@ fn pixel_at(image: &Image, index: usize) -> [u8; 4] { /// Returns validation errors, a resource-limit error on allocation failure, or /// an invalid-input error if luminosity cannot be converted to a byte. pub fn luminosity_mask(image: &Image) -> Result { - white_alpha_mask(image, |[r, g, b, _]| { + alpha_mask(image, [255; 3], |[r, g, b, _]| { // Preserve operation order and truncation, even for expanded gray: // simplifying to the gray byte or rounding can change the resulting alpha. // Input is straight RGB; multiplying by source alpha would change the mask. @@ -255,7 +264,7 @@ pub fn luminosity_mask(image: &Image) -> Result { /// # Errors /// Returns image validation errors or allocation failures. pub fn transfer_mask(image: &Image, transfer: &[u8; 256]) -> Result { - white_alpha_mask(image, |[_, _, _, alpha]| { + alpha_mask(image, [255; 3], |[_, _, _, alpha]| { transfer .get(usize::from(alpha)) .copied() @@ -263,18 +272,30 @@ pub fn transfer_mask(image: &Image, transfer: &[u8; 256]) -> Result Result { + alpha_mask(coverage, color, |[_, _, _, alpha]| Ok(alpha)) +} + +/// Builds a same-sized RGBA image of one color with alpha computed from each source pixel. /// /// Validates before iterating and allocates only the final output buffer. /// Validation, allocation, and alpha-calculation failures propagate to the caller. -fn white_alpha_mask( +fn alpha_mask( image: &Image, + [r, g, b]: [u8; 3], mut alpha: impl FnMut([u8; 4]) -> Result, ) -> Result { validate_image(image)?; let mut data = rgba_buffer(image.width, image.height)?; for pixel in rgba_pixels(image) { - data.extend_from_slice(&[255, 255, 255, alpha(pixel)?]); + data.extend_from_slice(&[r, g, b, alpha(pixel)?]); } Ok(Image { width: image.width, diff --git a/crates/pdf-image/src/reduced_image.rs b/crates/pdf-image/src/reduced_image.rs index 8a1c8c66..5c6318d7 100644 --- a/crates/pdf-image/src/reduced_image.rs +++ b/crates/pdf-image/src/reduced_image.rs @@ -74,7 +74,7 @@ impl PixelLayout { impl From for PixelLayout { fn from(format: PixelFormat) -> Self { match format { - PixelFormat::Gray8 => Self::Gray, + PixelFormat::Gray8 | PixelFormat::Alpha8 => Self::Gray, PixelFormat::RGBA8888 => Self::Rgba, } }