Skip to content
Open
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
33 changes: 32 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Test Python
name: Test

on:
# Trigger the workflow on push or pull request,
Expand All @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
187 changes: 187 additions & 0 deletions src/decode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
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());
}
}
59 changes: 59 additions & 0 deletions src/encode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}