diff --git a/crates/pdf-shading/src/free_form_mesh.rs b/crates/pdf-shading/src/free_form_mesh.rs index a107f30a..227caa95 100644 --- a/crates/pdf-shading/src/free_form_mesh.rs +++ b/crates/pdf-shading/src/free_form_mesh.rs @@ -12,7 +12,8 @@ use thiserror::Error; use crate::{ error::PdfShadingError, - mesh_decoder::{MeshBitWidths, MeshDecoder, read_mesh_bits}, + mesh_bit_widths::MeshBitWidths, + mesh_decoder::{MeshDecoder, read_mesh_bits}, model::{MeshTriangle, MeshVertex, Shading}, parse::{parse_functions, required_color_space}, }; @@ -74,11 +75,7 @@ impl FreeFormMeshConfig { objects: &dyn ObjectResolver, ) -> Result { let color_space = required_color_space(dictionary, objects)?; - let widths = MeshBitWidths::new( - dictionary.required_number::("BitsPerCoordinate", objects)?, - dictionary.required_number::("BitsPerComponent", objects)?, - dictionary.required_number::("BitsPerFlag", objects)?, - )?; + let widths = MeshBitWidths::from_dictionary(dictionary, objects)?; let decode = dictionary.required_vec_of::("Decode", objects)?; let bbox = dictionary.optional_bbox(objects)?; let anti_alias = dictionary.optional_boolean("AntiAlias", objects)?; diff --git a/crates/pdf-shading/src/lib.rs b/crates/pdf-shading/src/lib.rs index 0a6fe785..a48b6fa4 100644 --- a/crates/pdf-shading/src/lib.rs +++ b/crates/pdf-shading/src/lib.rs @@ -4,6 +4,7 @@ pub mod color_stops; pub mod error; mod free_form_mesh; pub mod mesh; +mod mesh_bit_widths; mod mesh_decoder; pub mod model; pub mod paint; diff --git a/crates/pdf-shading/src/mesh_bit_widths.rs b/crates/pdf-shading/src/mesh_bit_widths.rs new file mode 100644 index 00000000..543cb997 --- /dev/null +++ b/crates/pdf-shading/src/mesh_bit_widths.rs @@ -0,0 +1,107 @@ +//! Parsing and validation of packed mesh bit widths. + +use pdf_object::{ + dictionary::Dictionary, object_lookup::ObjectLookupExt, object_resolver::ObjectResolver, +}; + +use crate::{error::PdfShadingError, mesh_decoder::MeshDecoderError}; + +/// Coordinate widths permitted by the PDF specification for `/BitsPerCoordinate`. +const VALID_COORDINATE_WIDTHS: [usize; 8] = [1, 2, 4, 8, 12, 16, 24, 32]; + +/// Color component widths permitted by the PDF specification for `/BitsPerComponent`. +const VALID_COMPONENT_WIDTHS: [usize; 6] = [1, 2, 4, 8, 12, 16]; + +/// Edge-flag widths permitted by the PDF specification for `/BitsPerFlag`. +const VALID_FLAG_WIDTHS: [usize; 3] = [2, 4, 8]; + +/// Bit widths used to decode packed fields in a Type 4, 6, or 7 mesh stream. +/// +/// Each value is read from the corresponding mesh shading dictionary entry +/// and validated against the widths permitted by the PDF specification. +#[derive(Debug, Clone, Copy)] +pub(crate) struct MeshBitWidths { + /// Number of bits used to encode each coordinate component. + coordinate: usize, + /// Number of bits used to encode each color component or function input. + component: usize, + /// Number of bits used to encode each vertex or patch edge flag. + flag: usize, +} + +impl MeshBitWidths { + /// Reads and validates the bit widths declared by a mesh shading dictionary. + /// + /// The required `/BitsPerCoordinate`, `/BitsPerComponent`, and + /// `/BitsPerFlag` entries are resolved through `objects` before their + /// values are checked against the widths permitted by the PDF + /// specification. + /// + /// Returns an error when an entry is missing, cannot be resolved as a + /// non-negative integer, or specifies an unsupported width. + pub(crate) fn from_dictionary( + dictionary: &Dictionary, + objects: &dyn ObjectResolver, + ) -> Result { + let coordinate = dictionary.required_number::("BitsPerCoordinate", objects)?; + let component = dictionary.required_number::("BitsPerComponent", objects)?; + let flag = dictionary.required_number::("BitsPerFlag", objects)?; + + validate_allowed_width( + coordinate, + &VALID_COORDINATE_WIDTHS, + MeshDecoderError::InvalidBitsPerCoordinate { value: coordinate }, + )?; + validate_allowed_width( + component, + &VALID_COMPONENT_WIDTHS, + MeshDecoderError::InvalidBitsPerComponent { value: component }, + )?; + validate_allowed_width( + flag, + &VALID_FLAG_WIDTHS, + MeshDecoderError::InvalidBitsPerFlag { value: flag }, + )?; + + Ok(Self { + coordinate, + component, + flag, + }) + } + + /// Returns the number of bits used to encode each coordinate component. + pub(crate) fn coordinate(self) -> usize { + self.coordinate + } + + /// Returns the number of bits used to encode each color component or function input. + pub(crate) fn component(self) -> usize { + self.component + } + + /// Returns the number of bits used to encode each vertex or patch edge flag. + pub(crate) fn flag(self) -> usize { + self.flag + } +} + +/// Checks whether a mesh field width is one of the values permitted for that field. +/// +/// Returns `Ok(())` when `width` appears in `allowed`; otherwise converts the +/// field-specific `error` into [`PdfShadingError`]. +fn validate_allowed_width( + width: usize, + allowed: &[usize], + error: MeshDecoderError, +) -> Result<(), PdfShadingError> { + if allowed.contains(&width) { + Ok(()) + } else { + Err(error.into()) + } +} + +#[cfg(test)] +#[path = "../tests/mesh_bit_widths.rs"] +mod tests; diff --git a/crates/pdf-shading/src/mesh_decoder.rs b/crates/pdf-shading/src/mesh_decoder.rs index 45b1d02d..18499314 100644 --- a/crates/pdf-shading/src/mesh_decoder.rs +++ b/crates/pdf-shading/src/mesh_decoder.rs @@ -7,11 +7,7 @@ use pdf_graphics::{color::Color, point::Point}; use pdf_utils::BitReader; use thiserror::Error; -use crate::error::PdfShadingError; - -const VALID_COORDINATE_WIDTHS: [usize; 8] = [1, 2, 4, 8, 12, 16, 24, 32]; -const VALID_COMPONENT_WIDTHS: [usize; 6] = [1, 2, 4, 8, 12, 16]; -const VALID_FLAG_WIDTHS: [usize; 3] = [2, 4, 8]; +use crate::{error::PdfShadingError, mesh_bit_widths::MeshBitWidths}; /// Errors produced while validating and decoding packed mesh samples. #[derive(Debug, Error, Clone, PartialEq, Eq)] @@ -61,50 +57,6 @@ pub enum MeshDecoderError { SampleRangeNotRepresentable, } -/// Validated bit widths from a Type 4, 6, or 7 mesh dictionary. -#[derive(Debug, Clone, Copy)] -pub(crate) struct MeshBitWidths { - coordinate: usize, - component: usize, - flag: usize, -} - -impl MeshBitWidths { - /// Validates the three PDF mesh bit-width entries. - pub(crate) fn new( - coordinate: usize, - component: usize, - flag: usize, - ) -> Result { - validate_allowed_width( - coordinate, - &VALID_COORDINATE_WIDTHS, - MeshDecoderError::InvalidBitsPerCoordinate { value: coordinate }, - )?; - validate_allowed_width( - component, - &VALID_COMPONENT_WIDTHS, - MeshDecoderError::InvalidBitsPerComponent { value: component }, - )?; - validate_allowed_width( - flag, - &VALID_FLAG_WIDTHS, - MeshDecoderError::InvalidBitsPerFlag { value: flag }, - )?; - - Ok(Self { - coordinate, - component, - flag, - }) - } - - /// Returns the width of each edge-flag field. - pub(crate) fn flag(self) -> usize { - self.flag - } -} - /// Decodes mesh coordinates and color inputs according to a `/Decode` array. /// /// A decoder borrows the parsed dictionary data while a mesh parser owns the @@ -159,8 +111,8 @@ impl<'a> MeshDecoder<'a> { pub(crate) fn read_point(&self, reader: &mut BitReader<'_>) -> Result { let (x_min, x_max) = decode_pair(self.decode, 0, "X")?; let (y_min, y_max) = decode_pair(self.decode, 1, "Y")?; - let x = self.read_sample(reader, self.widths.coordinate, x_min, x_max)?; - let y = self.read_sample(reader, self.widths.coordinate, y_min, y_max)?; + let x = self.read_sample(reader, self.widths.coordinate(), x_min, x_max)?; + let y = self.read_sample(reader, self.widths.coordinate(), y_min, y_max)?; Ok(Point::new(x, y)) } @@ -171,7 +123,7 @@ impl<'a> MeshDecoder<'a> { .map(|component| { let (min, max) = decode_pair(self.decode, component.saturating_add(2), "component")?; - self.read_sample(reader, self.widths.component, min, max) + self.read_sample(reader, self.widths.component(), min, max) }) .collect::, PdfShadingError>>()?; @@ -179,6 +131,12 @@ impl<'a> MeshDecoder<'a> { Ok(self.color_space.apply(&components)?) } + /// Reads a required packed sample and maps it into the supplied decode range. + /// + /// The encoded integer is scaled linearly from the range representable by + /// `width` bits into `min..=max`. An error is returned when the stream ends + /// before the sample, contains only part of it, uses an unsupported width, + /// or the encoded value or its range cannot be represented as `f32`. fn read_sample( &self, reader: &mut BitReader<'_>, @@ -186,12 +144,22 @@ impl<'a> MeshDecoder<'a> { min: f32, max: f32, ) -> Result { - decode_sample( - read_required_mesh_bits(reader, width)?.into(), - width, - min, - max, - ) + let code = read_mesh_bits(reader, width)? + .ok_or_else(|| PdfShadingError::from(MeshDecoderError::UnexpectedEndOfStream))?; + let shift = u32::try_from(width) + .map_err(|_| PdfShadingError::from(MeshDecoderError::InvalidBitFieldWidth { width }))?; + let code_max = 1_u64 + .checked_shl(shift) + .ok_or_else(|| PdfShadingError::from(MeshDecoderError::InvalidBitFieldWidth { width }))? + .saturating_sub(1); + let code = u64::from(code) + .to_f32() + .ok_or_else(|| PdfShadingError::from(MeshDecoderError::SampleNotRepresentable))?; + let code_max = code_max + .to_f32() + .ok_or_else(|| PdfShadingError::from(MeshDecoderError::SampleRangeNotRepresentable))?; + + Ok(min + (code / code_max) * (max - min)) } fn apply_functions(&self, inputs: &[f32]) -> Result, PdfShadingError> { @@ -230,27 +198,6 @@ pub(crate) fn read_mesh_bits( .ok_or_else(|| PdfShadingError::from(MeshDecoderError::TruncatedSample)) } -/// Reads a mesh field that is required to complete the current record. -fn read_required_mesh_bits( - reader: &mut BitReader<'_>, - width: usize, -) -> Result { - read_mesh_bits(reader, width)? - .ok_or_else(|| PdfShadingError::from(MeshDecoderError::UnexpectedEndOfStream)) -} - -fn validate_allowed_width( - width: usize, - allowed: &[usize], - error: MeshDecoderError, -) -> Result<(), PdfShadingError> { - if allowed.contains(&width) { - Ok(()) - } else { - Err(error.into()) - } -} - fn decode_pair( decode: &[f32], pair_index: usize, @@ -269,23 +216,6 @@ fn decode_pair( Ok((min, max)) } -fn decode_sample(code: u64, width: usize, min: f32, max: f32) -> Result { - let shift = u32::try_from(width) - .map_err(|_| PdfShadingError::from(MeshDecoderError::InvalidBitFieldWidth { width }))?; - let code_max = 1_u64 - .checked_shl(shift) - .ok_or_else(|| PdfShadingError::from(MeshDecoderError::InvalidBitFieldWidth { width }))? - .saturating_sub(1); - let code = code - .to_f32() - .ok_or_else(|| PdfShadingError::from(MeshDecoderError::SampleNotRepresentable))?; - let code_max = code_max - .to_f32() - .ok_or_else(|| PdfShadingError::from(MeshDecoderError::SampleRangeNotRepresentable))?; - - Ok(min + (code / code_max) * (max - min)) -} - #[cfg(test)] #[path = "../tests/mesh_decoder.rs"] mod tests; diff --git a/crates/pdf-shading/src/patch_mesh_config.rs b/crates/pdf-shading/src/patch_mesh_config.rs index 22be9200..60f3a8b0 100644 --- a/crates/pdf-shading/src/patch_mesh_config.rs +++ b/crates/pdf-shading/src/patch_mesh_config.rs @@ -9,7 +9,8 @@ use pdf_object::{ use crate::{ error::PdfShadingError, - mesh_decoder::{MeshBitWidths, MeshDecoder}, + mesh_bit_widths::MeshBitWidths, + mesh_decoder::MeshDecoder, model::{MeshPatch, Shading, ShadingType}, parse::{parse_functions, required_color_space}, }; @@ -34,11 +35,7 @@ impl PatchMeshConfig { objects: &dyn ObjectResolver, ) -> Result { let color_space = required_color_space(dictionary, objects)?; - let widths = MeshBitWidths::new( - dictionary.required_number::("BitsPerCoordinate", objects)?, - dictionary.required_number::("BitsPerComponent", objects)?, - dictionary.required_number::("BitsPerFlag", objects)?, - )?; + let widths = MeshBitWidths::from_dictionary(dictionary, objects)?; let decode = dictionary.required_vec_of::("Decode", objects)?; let bbox = dictionary.optional_bbox(objects)?; let anti_alias = dictionary.optional_boolean("AntiAlias", objects)?; diff --git a/crates/pdf-shading/tests/mesh_bit_widths.rs b/crates/pdf-shading/tests/mesh_bit_widths.rs new file mode 100644 index 00000000..909b1bc7 --- /dev/null +++ b/crates/pdf-shading/tests/mesh_bit_widths.rs @@ -0,0 +1,72 @@ +//! Unit tests for mesh bit-width parsing and validation. + +use std::collections::BTreeMap; + +use pdf_object::{ + dictionary::Dictionary, error::ObjectError, object_resolver::PassthroughResolver, + object_variant::ObjectVariant, +}; + +use crate::{error::PdfShadingError, mesh_decoder::MeshDecoderError}; + +use super::MeshBitWidths; + +fn mesh_widths_dictionary(coordinate: i64, component: i64, flag: i64) -> Dictionary { + Dictionary::new(BTreeMap::from([ + ( + "BitsPerCoordinate".to_string(), + ObjectVariant::Integer(coordinate), + ), + ( + "BitsPerComponent".to_string(), + ObjectVariant::Integer(component), + ), + ("BitsPerFlag".to_string(), ObjectVariant::Integer(flag)), + ])) +} + +#[test] +fn validates_pdf_mesh_bit_widths() { + let valid = mesh_widths_dictionary(12, 4, 2); + let widths = MeshBitWidths::from_dictionary(&valid, &PassthroughResolver) + .expect("valid widths should parse"); + assert_eq!(widths.coordinate(), 12); + assert_eq!(widths.component(), 4); + assert_eq!(widths.flag(), 2); + + let invalid_coordinate = mesh_widths_dictionary(3, 4, 2); + assert!(matches!( + MeshBitWidths::from_dictionary(&invalid_coordinate, &PassthroughResolver), + Err(PdfShadingError::MeshDecoder( + MeshDecoderError::InvalidBitsPerCoordinate { value: 3 } + )) + )); + + let invalid_component = mesh_widths_dictionary(8, 32, 2); + assert!(matches!( + MeshBitWidths::from_dictionary(&invalid_component, &PassthroughResolver), + Err(PdfShadingError::MeshDecoder( + MeshDecoderError::InvalidBitsPerComponent { value: 32 } + )) + )); + + let invalid_flag = mesh_widths_dictionary(8, 8, 1); + assert!(matches!( + MeshBitWidths::from_dictionary(&invalid_flag, &PassthroughResolver), + Err(PdfShadingError::MeshDecoder( + MeshDecoderError::InvalidBitsPerFlag { value: 1 } + )) + )); +} + +#[test] +fn mesh_bit_widths_require_all_dictionary_entries() { + let mut dictionary = mesh_widths_dictionary(8, 8, 2); + dictionary.take("BitsPerFlag"); + + assert!(matches!( + MeshBitWidths::from_dictionary(&dictionary, &PassthroughResolver), + Err(PdfShadingError::Object(ObjectError::MissingRequiredKey { ref key })) + if key == "BitsPerFlag" + )); +} diff --git a/crates/pdf-shading/tests/mesh_decoder.rs b/crates/pdf-shading/tests/mesh_decoder.rs index 14058825..e119262d 100644 --- a/crates/pdf-shading/tests/mesh_decoder.rs +++ b/crates/pdf-shading/tests/mesh_decoder.rs @@ -4,40 +4,7 @@ use pdf_utils::BitReader; use crate::error::PdfShadingError; -use super::{ - MeshBitWidths, MeshDecoderError, decode_sample, read_mesh_bits, read_required_mesh_bits, -}; - -#[test] -fn decodes_sample_range_endpoints_and_midpoint() { - assert_eq!(decode_sample(0, 8, -1.0, 1.0).expect("minimum"), -1.0); - assert_eq!(decode_sample(255, 8, -1.0, 1.0).expect("maximum"), 1.0); - let midpoint = decode_sample(128, 8, 0.0, 1.0).expect("midpoint"); - assert!((midpoint - (128.0 / 255.0)).abs() < f32::EPSILON); -} - -#[test] -fn validates_pdf_mesh_bit_widths() { - assert!(MeshBitWidths::new(12, 4, 2).is_ok()); - assert!(matches!( - MeshBitWidths::new(3, 4, 2), - Err(PdfShadingError::MeshDecoder( - MeshDecoderError::InvalidBitsPerCoordinate { value: 3 } - )) - )); - assert!(matches!( - MeshBitWidths::new(8, 32, 2), - Err(PdfShadingError::MeshDecoder( - MeshDecoderError::InvalidBitsPerComponent { value: 32 } - )) - )); - assert!(matches!( - MeshBitWidths::new(8, 8, 1), - Err(PdfShadingError::MeshDecoder( - MeshDecoderError::InvalidBitsPerFlag { value: 1 } - )) - )); -} +use super::{MeshDecoderError, read_mesh_bits}; #[test] fn mesh_reads_distinguish_clean_eof_from_truncation() { @@ -52,14 +19,6 @@ fn mesh_reads_distinguish_clean_eof_from_truncation() { )) )); assert_eq!(truncated.pos(), 0); - - let mut required = BitReader::new(&[]); - assert!(matches!( - read_required_mesh_bits(&mut required, 8), - Err(PdfShadingError::MeshDecoder( - MeshDecoderError::UnexpectedEndOfStream - )) - )); } #[test]