From 799dc9c6fd141f21ce97cbb82912cdf5a8877bd6 Mon Sep 17 00:00:00 2001 From: Velli20 Date: Sun, 11 Oct 2026 21:54:35 +0300 Subject: [PATCH] Convert non-device image samples without whole-image intermediates Reading a 12608x16806 JPEG 2000 image in an ICCBased space with a soft mask peaked at 5.5 GB: every sample was widened to f32 before color conversion, and the RGBA buffer was copied again to apply the mask. Samples are now converted to RGBA one pixel at a time, the soft mask is multiplied into the decoded buffer in place, and an ICCBased space whose alternate is a device space is decoded as that device space, which is how it is drawn until ICC color management exists. The read now peaks at 2.2 GB and takes 8 s instead of 57 s in the regression build. Fixes #520 Co-Authored-By: Claude Opus 5.5 --- crates/pdf-color-space/src/color_space.rs | 14 ++ .../src/icc_based_color_space.rs | 17 ++ crates/pdf-decode/src/map.rs | 22 +- crates/pdf-image/src/decoded_samples.rs | 194 ++++++++++-------- crates/pdf-image/src/image_decoder.rs | 4 +- 5 files changed, 154 insertions(+), 97 deletions(-) diff --git a/crates/pdf-color-space/src/color_space.rs b/crates/pdf-color-space/src/color_space.rs index fc8a2ee8..0d2603ca 100644 --- a/crates/pdf-color-space/src/color_space.rs +++ b/crates/pdf-color-space/src/color_space.rs @@ -93,6 +93,20 @@ impl ColorSpace { matches!(self, Self::DeviceGray | Self::DeviceRGB | Self::DeviceCMYK) } + /// Returns the device space that draws this space's samples unchanged, if there is one. + /// + /// A device space is its own equivalent. An ICCBased space draws through its + /// alternate until ICC colour management exists, so it has one when that alternate + /// does. + #[must_use] + pub fn device_equivalent(&self) -> Option { + match self { + Self::ICCBased(icc) => icc.device_equivalent(), + space if space.is_device_space() => Some(space.clone()), + _ => None, + } + } + pub fn apply(&self, components: &[f32]) -> Result { match self { ColorSpace::Indexed(indexed) => indexed.apply(components), diff --git a/crates/pdf-color-space/src/icc_based_color_space.rs b/crates/pdf-color-space/src/icc_based_color_space.rs index 790f30c1..dfe47998 100644 --- a/crates/pdf-color-space/src/icc_based_color_space.rs +++ b/crates/pdf-color-space/src/icc_based_color_space.rs @@ -54,6 +54,23 @@ impl FromPdfObject for ICCBasedColorSpace { } impl ICCBasedColorSpace { + /// Returns the device space this space draws as: its alternate's device equivalent, + /// or the device space with its number of components when it names no alternate. + /// + /// An alternate with a different number of components has none. + pub(crate) fn device_equivalent(&self) -> Option { + let space = match self.alternate_space.as_deref() { + Some(alternate) => alternate.device_equivalent()?, + None => match self.num_components { + 1 => ColorSpace::DeviceGray, + 3 => ColorSpace::DeviceRGB, + 4 => ColorSpace::DeviceCMYK, + _ => return None, + }, + }; + (space.num_color_components() == self.num_components).then_some(space) + } + pub(crate) fn apply(&self, components: &[f32]) -> Result { if components.len() != self.num_components { return Err(ColorSpaceError::InsufficientComponents( diff --git a/crates/pdf-decode/src/map.rs b/crates/pdf-decode/src/map.rs index a9a5b9b1..04c6c1e0 100644 --- a/crates/pdf-decode/src/map.rs +++ b/crates/pdf-decode/src/map.rs @@ -44,18 +44,16 @@ impl DecodeMap { out } - /// Applies the decode ranges while retaining their floating-point component domains. - #[must_use] - pub fn apply_to_f32(&self, samples: &[u8], sample_max: u8) -> Vec { - if self.ranges.is_empty() { - return Vec::new(); - } - - samples + /// Applies the decode ranges to `samples`, writing their floating-point component values + /// into `out`. Writing stops at the shorter of the two slices. + pub fn apply_to_f32(&self, samples: &[u8], sample_max: u8, out: &mut [f32]) { + for ((sample, range), value) in samples .iter() .zip(self.ranges.iter().cycle()) - .map(|(sample, range)| range.map_f32(*sample, sample_max)) - .collect() + .zip(out.iter_mut()) + { + *value = range.map_f32(*sample, sample_max); + } } } @@ -98,6 +96,8 @@ mod tests { ranges: vec![DecodeRange::new(-100.0, 100.0).unwrap()], }; - assert_eq!(map.apply_to_f32(&[0, 255], 255), vec![-100.0, 100.0]); + let mut values = [0.0; 2]; + map.apply_to_f32(&[0, 255], 255, &mut values); + assert_eq!(values, [-100.0, 100.0]); } } diff --git a/crates/pdf-image/src/decoded_samples.rs b/crates/pdf-image/src/decoded_samples.rs index 023b63b1..89f5e35f 100644 --- a/crates/pdf-image/src/decoded_samples.rs +++ b/crates/pdf-image/src/decoded_samples.rs @@ -8,7 +8,7 @@ use pdf_decode::{ }; use pdf_filter::image_payload::ImagePayload; -use crate::error::PdfImageError; +use crate::error::{ImageRasterError, PdfImageError}; use crate::image_metadata::ImageMetadata; use crate::jpx_image::{JpxDisplay, JpxImage}; @@ -43,25 +43,22 @@ impl DecodedSamples { } match &metadata.color_space { - Some(ColorSpace::Indexed(indexed)) => { - if indexed.base.is_device_space() { - Self::decode_indexed( - raw_data, - metadata, - indexed.base.num_color_components(), - indexed.hival, - &indexed.lookup, - ) - } else { - Self::decode_indexed_rgba(raw_data, metadata, indexed) + Some(ColorSpace::Indexed(indexed)) => match indexed.base.device_equivalent() { + Some(base) => Self::decode_indexed( + raw_data, + metadata, + base.num_color_components(), + indexed.hival, + &indexed.lookup, + ), + None => Self::decode_indexed_rgba(raw_data, metadata, indexed), + }, + Some(color_space) => match color_space.device_equivalent() { + Some(device) => { + Self::decode_direct(raw_data, metadata, device.num_color_components()) } - } - Some(color_space) if !color_space.is_device_space() => { - Self::decode_direct_rgba(raw_data, metadata, color_space) - } - Some(color_space) => { - Self::decode_direct(raw_data, metadata, color_space.num_color_components()) - } + None => Self::decode_direct_rgba(raw_data, metadata, color_space), + }, None => Self::decode_direct(raw_data, metadata, 1), } } @@ -85,7 +82,7 @@ impl DecodedSamples { metadata.size.height(), )?; - if alpha.is_none() && color_space.is_device_space() { + if alpha.is_none() && color_space.device_equivalent().is_some() { return Ok(Self { num_color_components: components, image_data: color.into(), @@ -93,28 +90,13 @@ impl DecodedSamples { }); } - // An Indexed space reads its component as a palette index rather than a - // fraction of full scale, so the sample passes through unnormalized. - let decoded = match color_space { - ColorSpace::Indexed(_) => color.iter().map(|sample| f32::from(*sample)).collect(), - ref color_space => Self::default_color_components(&color, u8::MAX, color_space), - }; - let mut image_data = Vec::new(); - let pixels = decoded.len().checked_div(components.max(1)).unwrap_or(0); - image_data - .try_reserve_exact(pixels.saturating_mul(4)) - .map_err(|_| PdfImageError::JpxImageTooLarge { - pixels, - channels: 4, - })?; - for (index, pixel) in decoded.chunks_exact(components.max(1)).enumerate() { - let [red, green, blue, _] = color_space.apply(pixel)?.to_rgba8(); - let opacity = alpha - .as_ref() - .and_then(|plane| plane.get(index).copied()) - .unwrap_or(u8::MAX); - image_data.extend_from_slice(&[red, green, blue, opacity]); - } + // PDF ignores `/Decode` for a JPX image. + let image_data = ComponentDomain::new(&color_space, None).to_rgba( + &color, + u8::MAX, + &color_space, + alpha.as_deref(), + )?; Ok(Self { num_color_components: 4, @@ -284,20 +266,12 @@ impl DecodedSamples { let components = color_space.num_color_components(); let sample_codes = Self::decode_image_sample_codes(raw_data, components, metadata)?; let sample_max = Self::sample_max(metadata.sample_bits())?; - let decoded = metadata.decode.as_ref().map_or_else( - || Self::default_color_components(sample_codes.as_ref(), sample_max, color_space), - |decode| decode.apply_to_f32(sample_codes.as_ref(), sample_max), - ); - let mut image_data = Vec::with_capacity( - decoded - .len() - .checked_div(components) - .unwrap_or(0) - .saturating_mul(4), - ); - for pixel in decoded.chunks_exact(components) { - image_data.extend_from_slice(&color_space.apply(pixel)?.to_rgba8()); - } + let image_data = ComponentDomain::new(color_space, metadata.decode.as_ref()).to_rgba( + &sample_codes, + sample_max, + color_space, + None, + )?; Ok(Self { num_color_components: 4, @@ -306,35 +280,6 @@ impl DecodedSamples { }) } - /// Applies the implicit decode domains for non-device color spaces. - fn default_color_components( - samples: &[u8], - sample_max: u8, - color_space: &ColorSpace, - ) -> Vec { - let denominator = f32::from(sample_max.max(1)); - match color_space { - ColorSpace::Lab(lab) => samples - .as_chunks::<3>() - .0 - .iter() - .flat_map(|&[l, a, b]| { - let [amin, amax, bmin, bmax] = lab.range; - let normalized = |sample| f32::from(sample) / denominator; - [ - normalized(l) * 100.0, - amin + normalized(a) * (amax - amin), - bmin + normalized(b) * (bmax - bmin), - ] - }) - .collect(), - _ => samples - .iter() - .map(|sample| f32::from(*sample) / denominator) - .collect(), - } - } - /// Applies an explicit decode map or the implicit PDF identity/inverted default. fn apply_decode( sample_codes: Bytes, @@ -425,6 +370,85 @@ impl DecodedSamples { } } +/// How a non-device color space reads one pixel's samples as color components. +enum ComponentDomain<'a> { + /// An explicit `/Decode` array maps each sample into its component range. + Decode(&'a DecodeMap), + /// An Indexed space reads its component as a palette index rather than a + /// fraction of full scale, so the sample passes through unnormalized. + Index, + /// `L*a*b*` samples span L* 0 to 100 and the space's `[amin amax bmin bmax]` ranges. + Lab([f32; 4]), + /// Samples are fractions of full scale. + Fraction, +} + +impl<'a> ComponentDomain<'a> { + /// Selects the domain of `color_space`, or of `decode` when the image gives one. + fn new(color_space: &ColorSpace, decode: Option<&'a DecodeMap>) -> Self { + match (decode, color_space) { + (Some(decode), _) => Self::Decode(decode), + (None, ColorSpace::Indexed(_)) => Self::Index, + (None, ColorSpace::Lab(lab)) => Self::Lab(lab.range), + (None, _) => Self::Fraction, + } + } + + /// Writes the color components of one pixel's samples into `components`. + fn read(&self, pixel: &[u8], sample_max: u8, components: &mut [f32]) { + let denominator = match self { + Self::Index => 1.0, + _ => f32::from(sample_max.max(1)), + }; + let normalized = |sample: u8| f32::from(sample) / denominator; + match self { + Self::Decode(decode) => decode.apply_to_f32(pixel, sample_max, components), + Self::Lab([amin, amax, bmin, bmax]) => { + if let ([l, a, b, ..], [cl, ca, cb, ..]) = (pixel, components) { + *cl = normalized(*l) * 100.0; + *ca = amin + normalized(*a) * (amax - amin); + *cb = bmin + normalized(*b) * (bmax - bmin); + } + } + Self::Index | Self::Fraction => { + for (sample, component) in pixel.iter().zip(components.iter_mut()) { + *component = normalized(*sample); + } + } + } + } + + /// Converts interleaved samples through `color_space` into RGBA, one pixel at a time, + /// so no intermediate copy of the whole image is held in floating point. + /// + /// Opacity comes from `alpha`, one value per pixel, when it is given, and otherwise + /// from the color the space produces. + fn to_rgba( + &self, + samples: &[u8], + sample_max: u8, + color_space: &ColorSpace, + alpha: Option<&[u8]>, + ) -> Result, PdfImageError> { + let components = color_space.num_color_components().max(1); + let pixels = samples.len().checked_div(components).unwrap_or(0); + let mut image_data = Vec::new(); + image_data + .try_reserve_exact(pixels.saturating_mul(4)) + .map_err(|_| PdfImageError::Raster(ImageRasterError::ResourceLimit))?; + let mut pixel = vec![0.0; components]; + for (index, samples) in samples.chunks_exact(components).enumerate() { + self.read(samples, sample_max, &mut pixel); + let [red, green, blue, color_alpha] = color_space.apply(&pixel)?.to_rgba8(); + let opacity = alpha + .and_then(|plane| plane.get(index).copied()) + .unwrap_or(color_alpha); + image_data.extend_from_slice(&[red, green, blue, opacity]); + } + Ok(image_data) + } +} + #[cfg(test)] mod tests { use std::collections::BTreeMap; diff --git a/crates/pdf-image/src/image_decoder.rs b/crates/pdf-image/src/image_decoder.rs index a3407ef8..4c7f94a4 100644 --- a/crates/pdf-image/src/image_decoder.rs +++ b/crates/pdf-image/src/image_decoder.rs @@ -182,7 +182,9 @@ fn image_from_rgba( }; } - let mut rgba = data.get(..byte_len).map_or_else(Vec::new, <[u8]>::to_vec); + // Freshly decoded samples are not shared, so this takes over their buffer without a copy. + let mut rgba = Vec::from(data); + rgba.truncate(byte_len); if let Some(mask) = soft_mask { for ([_, _, _, alpha], mask_alpha) in rgba .as_chunks_mut::<4>()