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
11 changes: 9 additions & 2 deletions crates/pdf-canvas/src/canvas_external_object_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -94,11 +94,18 @@ impl<B: CanvasBackend> 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
Expand Down
1 change: 1 addition & 0 deletions crates/pdf-graphics-skia/src/skia_canvas_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ fn to_skia_image(image: &Image) -> Result<skia_safe::Image, PdfCanvasError> {
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(
Expand Down
2 changes: 2 additions & 0 deletions crates/pdf-graphics/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
21 changes: 14 additions & 7 deletions crates/pdf-image/src/decoded_samples.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
14 changes: 11 additions & 3 deletions crates/pdf-image/src/image_decoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]);
}

Expand Down
51 changes: 36 additions & 15 deletions crates/pdf-image/src/raster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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<Item = [u8; 4]> + '_ {
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
Expand All @@ -118,7 +122,7 @@ pub fn rgba(image: &Image) -> Result<Vec<u8>, 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);
}
Expand Down Expand Up @@ -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] {
Expand All @@ -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
Expand All @@ -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<Image, ImageRasterError> {
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.
Expand All @@ -255,26 +264,38 @@ pub fn luminosity_mask(image: &Image) -> Result<Image, ImageRasterError> {
/// # Errors
/// Returns image validation errors or allocation failures.
pub fn transfer_mask(image: &Image, transfer: &[u8; 256]) -> Result<Image, ImageRasterError> {
white_alpha_mask(image, |[_, _, _, alpha]| {
alpha_mask(image, [255; 3], |[_, _, _, alpha]| {
transfer
.get(usize::from(alpha))
.copied()
.ok_or(ImageRasterError::InvalidInput("mask transfer"))
})
}

/// Builds a same-sized white RGBA image with alpha computed from each source pixel.
/// Paints a stencil mask's coverage in a solid color.
///
/// Each output pixel takes `color` with the mask's coverage as its alpha, so samples
/// the mask leaves unpainted become transparent.
///
/// # Errors
/// Returns image validation errors or allocation failures.
pub fn stencil_image(coverage: &Image, color: [u8; 3]) -> Result<Image, ImageRasterError> {
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<u8, ImageRasterError>,
) -> Result<Image, ImageRasterError> {
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,
Expand Down
2 changes: 1 addition & 1 deletion crates/pdf-image/src/reduced_image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ impl PixelLayout {
impl From<PixelFormat> for PixelLayout {
fn from(format: PixelFormat) -> Self {
match format {
PixelFormat::Gray8 => Self::Gray,
PixelFormat::Gray8 | PixelFormat::Alpha8 => Self::Gray,
PixelFormat::RGBA8888 => Self::Rgba,
}
}
Expand Down
Loading