diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d7eca5e..9e95afa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: Test Python +name: Test on: # Trigger the workflow on push or pull request, @@ -14,7 +14,38 @@ env: RUSTFLAGS: -C debuginfo=0 # Do not produce debug symbols to keep memory usage down RUST_BACKTRACE: 1 +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + jobs: + test-rust: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + submodules: recursive + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Make libpython discoverable at runtime + run: echo "LD_LIBRARY_PATH=${pythonLocation}/lib" >> "$GITHUB_ENV" + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust + uses: Swatinem/rust-cache@v2 + + - name: Run tests + run: cargo test --features vendored + test-python: runs-on: ${{ matrix.os }} strategy: diff --git a/Cargo.toml b/Cargo.toml index ff0fb0d..bb17b0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,9 @@ name = "pillow_jxl" crate-type = ["cdylib"] [dependencies] -pyo3 = { version="0.29.0", features = ["extension-module"] } +# extension-module is passed via pyproject.toml's [tool.maturin] features instead of +# forced here, since forcing it breaks `cargo test`/`cargo build` (no libpython link). +pyo3 = { version="0.29.0" } jpegxl-rs = { version="0.15.0", default-features = false } half = "2.7.1" bytemuck = "1.24.0" diff --git a/src/decode.rs b/src/decode.rs index b31b5bf..5add8d6 100644 --- a/src/decode.rs +++ b/src/decode.rs @@ -306,3 +306,190 @@ impl Decoder { fn to_pyjxlerror(e: DecodeError) -> PyErr { PyRuntimeError::new_err(e.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + const SIGNATURE: [u8; 12] = *b"\x00\x00\x00\x0c\x4a\x58\x4c\x20\x0d\x0a\x87\x0a"; + const HEADER_SIZE: usize = 32; + + /// Builds a container header of exactly HEADER_SIZE bytes: the signature + /// followed by padding (real files put an ftyp box here, but extract_boxes + /// doesn't look at it). + fn container_header() -> Vec { + let mut header = SIGNATURE.to_vec(); + header.resize(HEADER_SIZE, 0); + header + } + + #[test] + fn empty_input_is_not_a_container() { + let result = extract_boxes(&[]).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn input_shorter_than_signature_is_not_a_container() { + let data = &SIGNATURE[..5]; + let result = extract_boxes(data).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn header_only_with_no_boxes() { + let data = container_header(); + let result = extract_boxes(&data).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn single_well_formed_32_bit_box() { + let mut data = container_header(); + let box_data = b"hello"; + let box_len = 8 + box_data.len() as u32; + data.extend_from_slice(&box_len.to_be_bytes()); + data.extend_from_slice(b"jxlc"); + data.extend_from_slice(box_data); + + let boxes = extract_boxes(&data).unwrap(); + assert_eq!(boxes.len(), 1); + assert_eq!(&boxes[0].box_type, b"jxlc"); + assert_eq!(boxes[0].data, box_data); + } + + #[test] + fn zero_box_size_extends_to_end_of_data() { + let mut data = container_header(); + data.extend_from_slice(&0u32.to_be_bytes()); // size == 0 -> "to end of data" + data.extend_from_slice(b"jxlp"); + let box_data = b"remaining bytes"; + data.extend_from_slice(box_data); + + let boxes = extract_boxes(&data).unwrap(); + assert_eq!(boxes.len(), 1); + assert_eq!(&boxes[0].box_type, b"jxlp"); + assert_eq!(boxes[0].data, box_data); + } + + #[test] + fn sixty_four_bit_box_size_escape() { + let mut data = container_header(); + let box_data = b"large box payload"; + let real_size = 16 + box_data.len() as u64; + data.extend_from_slice(&1u32.to_be_bytes()); // box_size == 1 -> 64-bit escape + data.extend_from_slice(b"jxlp"); + data.extend_from_slice(&real_size.to_be_bytes()); + data.extend_from_slice(box_data); + + let boxes = extract_boxes(&data).unwrap(); + assert_eq!(boxes.len(), 1); + assert_eq!(&boxes[0].box_type, b"jxlp"); + assert_eq!(boxes[0].data, box_data); + } + + #[test] + fn sixty_four_bit_box_size_smaller_than_header_is_an_error() { + let mut data = container_header(); + data.extend_from_slice(&1u32.to_be_bytes()); + data.extend_from_slice(b"jxlp"); + data.extend_from_slice(&10u64.to_be_bytes()); // < 16, invalid + data.extend_from_slice(b"padding_bytes_ok"); + + let result = extract_boxes(&data); + assert!(result.is_err()); + } + + #[test] + fn box_extending_past_end_of_data_stops_parsing_without_error() { + let mut data = container_header(); + + // First box: well-formed and should be parsed successfully. + let first_data = b"ok"; + let first_len = 8 + first_data.len() as u32; + data.extend_from_slice(&first_len.to_be_bytes()); + data.extend_from_slice(b"jxlc"); + data.extend_from_slice(first_data); + + // Second box: declares a length far past the end of the buffer. + data.extend_from_slice(&1_000u32.to_be_bytes()); + data.extend_from_slice(b"bad!"); + + let boxes = extract_boxes(&data).unwrap(); + assert_eq!(boxes.len(), 1); + assert_eq!(&boxes[0].box_type, b"jxlc"); + assert_eq!(boxes[0].data, first_data); + } + + #[test] + fn reserved_32_bit_box_size_is_an_error() { + let mut data = container_header(); + data.extend_from_slice(&3u32.to_be_bytes()); // 2..=7 is reserved/invalid + data.extend_from_slice(b"jxlc"); + + let result = extract_boxes(&data); + assert!(result.is_err()); + } + + #[test] + fn truncated_64_bit_box_header_stops_parsing_without_error() { + let mut data = container_header(); + data.extend_from_slice(&1u32.to_be_bytes()); // box_size == 1 -> 64-bit escape + data.extend_from_slice(b"jxlp"); + // Missing the 8-byte large-size field entirely (truncated file). + + let boxes = extract_boxes(&data).unwrap(); + assert!(boxes.is_empty()); + } + + #[test] + fn empty_payload_32_bit_box() { + let mut data = container_header(); + data.extend_from_slice(&8u32.to_be_bytes()); // size == header_length, no payload + data.extend_from_slice(b"jxlc"); + + let boxes = extract_boxes(&data).unwrap(); + assert_eq!(boxes.len(), 1); + assert_eq!(&boxes[0].box_type, b"jxlc"); + assert!(boxes[0].data.is_empty()); + } + + #[test] + fn image_info_mode_grayscale_8bit() { + assert_eq!(ImageInfo::mode(1, false, None).unwrap(), "L"); + } + + #[test] + fn image_info_mode_grayscale_alpha_8bit() { + assert_eq!(ImageInfo::mode(1, true, None).unwrap(), "LA"); + } + + #[test] + fn image_info_mode_rgba() { + assert_eq!(ImageInfo::mode(3, true, None).unwrap(), "RGBA"); + } + + #[test] + fn image_info_mode_float16_grayscale() { + let pixels = Pixels::Float16(vec![]); + assert_eq!(ImageInfo::mode(1, false, Some(&pixels)).unwrap(), "F;16"); + } + + #[test] + fn image_info_mode_uint16_grayscale() { + let pixels = Pixels::Uint16(vec![]); + assert_eq!(ImageInfo::mode(1, false, Some(&pixels)).unwrap(), "I;16"); + } + + #[test] + fn image_info_mode_float_grayscale() { + let pixels = Pixels::Float(vec![]); + assert_eq!(ImageInfo::mode(1, false, Some(&pixels)).unwrap(), "F"); + } + + #[test] + fn image_info_mode_unsupported_combination_errors() { + let result = ImageInfo::mode(2, false, None); + assert!(result.is_err()); + } +} diff --git a/src/encode.rs b/src/encode.rs index b40b2bc..80f1c38 100644 --- a/src/encode.rs +++ b/src/encode.rs @@ -248,3 +248,62 @@ impl Encoder { fn to_pyjxlerror(e: EncodeError) -> PyErr { PyRuntimeError::new_err(e.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uint8_luma_color_encoding_and_alpha() { + let l = PixelType::Uint8 { + num_channels: 1, + has_alpha: false, + }; + assert!(matches!(l.color_encoding(), ColorEncoding::SrgbLuma)); + assert!(!l.has_alpha()); + + let la = PixelType::Uint8 { + num_channels: 2, + has_alpha: true, + }; + assert!(matches!(la.color_encoding(), ColorEncoding::SrgbLuma)); + assert!(la.has_alpha()); + } + + #[test] + fn uint8_rgb_color_encoding_and_alpha() { + let rgb = PixelType::Uint8 { + num_channels: 3, + has_alpha: false, + }; + assert!(matches!(rgb.color_encoding(), ColorEncoding::Srgb)); + assert!(!rgb.has_alpha()); + + let rgba = PixelType::Uint8 { + num_channels: 4, + has_alpha: true, + }; + assert!(matches!(rgba.color_encoding(), ColorEncoding::Srgb)); + assert!(rgba.has_alpha()); + } + + #[test] + fn uint16_color_encoding_and_alpha() { + let uint16 = PixelType::Uint16; + assert!(matches!( + uint16.color_encoding(), + ColorEncoding::LinearSrgbLuma + )); + assert!(!uint16.has_alpha()); + } + + #[test] + fn float32_color_encoding_and_alpha() { + let float32 = PixelType::Float32; + assert!(matches!( + float32.color_encoding(), + ColorEncoding::LinearSrgbLuma + )); + assert!(!float32.has_alpha()); + } +}