use crate::error::{Error, Result};
pub struct JpxDecoder;
impl super::StreamDecoder for JpxDecoder {
fn decode(&self, input: &[u8], _max_output_bytes: usize) -> Result<Vec<u8>> {
Ok(input.to_vec())
}
fn name(&self) -> &str {
"JPXDecode"
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct JpxImage {
pub samples: Vec<u8>,
pub num_components: u8,
pub width: u32,
pub height: u32,
pub opacity: Option<Vec<u8>>,
}
fn alpha_is_droppable(codestream_says_alpha: bool, num_components: usize, declared_components: Option<u8>) -> bool {
codestream_says_alpha
&& num_components > 1
&& declared_components.is_none_or(|declared| usize::from(declared) != num_components)
}
const SOC_MARKER: [u8; 2] = [0xFF, 0x4F];
const SOC_THEN_SIZ: [u8; 4] = [0xFF, 0x4F, 0xFF, 0x51];
const JP2C_BOX_TYPE: [u8; 4] = *b"jp2c";
const CSIZ_OFFSET_IN_CODESTREAM: usize = 40;
const SIZ_BYTES_BEFORE_COMPONENTS: u16 = 38;
const SIZ_BYTES_PER_COMPONENT: u16 = 3;
fn codestream_component_count(bytes: &[u8]) -> Option<u8> {
let codestream = locate_codestream(bytes)?;
if !codestream.starts_with(&SOC_THEN_SIZ) {
return None;
}
let lsiz = u16::from_be_bytes(codestream.get(4..6)?.try_into().ok()?);
let csiz_end = CSIZ_OFFSET_IN_CODESTREAM + 2;
let csiz = u16::from_be_bytes(codestream.get(CSIZ_OFFSET_IN_CODESTREAM..csiz_end)?.try_into().ok()?);
let expected_lsiz = SIZ_BYTES_BEFORE_COMPONENTS.checked_add(SIZ_BYTES_PER_COMPONENT.checked_mul(csiz)?)?;
if lsiz != expected_lsiz {
return None;
}
u8::try_from(csiz).ok().filter(|&n| n > 0)
}
fn locate_codestream(bytes: &[u8]) -> Option<&[u8]> {
if bytes.starts_with(&SOC_MARKER) {
return Some(bytes);
}
find_box(bytes, &JP2C_BOX_TYPE)
}
fn find_box<'a>(bytes: &'a [u8], wanted: &[u8; 4]) -> Option<&'a [u8]> {
let mut offset = 0usize;
while offset.checked_add(8).is_some_and(|end| end <= bytes.len()) {
let lbox = u32::from_be_bytes(bytes.get(offset..offset + 4)?.try_into().ok()?);
let box_type = bytes.get(offset + 4..offset + 8)?;
let (header_len, box_len) = match lbox {
1 => {
let xlbox = u64::from_be_bytes(bytes.get(offset + 8..offset + 16)?.try_into().ok()?);
(16usize, usize::try_from(xlbox).ok()?)
}
0 => (8usize, bytes.len() - offset),
len => (8usize, usize::try_from(len).ok()?),
};
if box_len < header_len {
return None;
}
if box_type == wanted {
let end = offset.saturating_add(box_len).min(bytes.len());
return bytes.get(offset + header_len..end);
}
offset = offset.checked_add(box_len)?;
}
None
}
pub struct CodestreamPalette {
pub columns: u8,
pub entries: Vec<u8>,
}
pub fn codestream_palette(bytes: &[u8]) -> Option<CodestreamPalette> {
let header = find_box(bytes, b"jp2h")?;
let pclr = find_box(header, b"pclr")?;
let entry_count = usize::from(u16::from_be_bytes(pclr.get(0..2)?.try_into().ok()?));
let columns = *pclr.get(2)?;
let depths = pclr.get(3..3 + usize::from(columns))?;
if !(1..=256).contains(&entry_count) || !matches!(columns, 1 | 3 | 4) || depths.iter().any(|&b| b != 7) {
return None;
}
let start = 3 + usize::from(columns);
let entries = pclr.get(start..start + entry_count * usize::from(columns))?.to_vec();
let cmap = find_box(header, b"cmap")?;
if cmap.len() % 4 != 0 || cmap.len() < 4 * usize::from(columns) {
return None;
}
let (palette_channels, direct_channels) = cmap.split_at(4 * usize::from(columns));
let in_order = palette_channels
.chunks_exact(4)
.zip(0u8..)
.all(|(channel, column)| channel == [0, 0, 1, column]);
if !in_order || direct_channels.chunks_exact(4).any(|channel| channel[2] != 0) {
return None;
}
if let Some(colr) = find_box(header, b"colr")
&& colr.first() == Some(&1)
{
let enumcs = u32::from_be_bytes(colr.get(3..7)?.try_into().ok()?);
let expected = match enumcs {
12 => 4,
16 => 3,
17 => 1,
_ => return None,
};
if expected != columns {
return None;
}
}
Some(CodestreamPalette { columns, entries })
}
fn undeclared_component_count(bytes: &[u8]) -> Option<u8> {
if !bytes.starts_with(&SOC_MARKER) {
return None;
}
codestream_component_count(bytes)
}
pub fn decode_jpx(bytes: &[u8], declared_components: Option<u8>) -> Result<JpxImage> {
use hayro_jpeg2000::{DecodeSettings, DecoderContext, Image};
let declared_components = declared_components.or_else(|| undeclared_component_count(bytes));
let image = Image::new(bytes, &DecodeSettings::default())
.map_err(|e| Error::UnsupportedFilter(format!("JPXDecode: JPEG 2000 decode failed: {e:?}")))?;
let width = image.width();
let height = image.height();
let npix = width as usize * height as usize;
let mut ctx = DecoderContext::default();
let decoded = image
.decode(&mut ctx)
.map_err(|e| Error::UnsupportedFilter(format!("JPXDecode: JPEG 2000 decode failed: {e:?}")))?;
let comps = decoded.components();
if comps.is_empty() {
return Err(Error::UnsupportedFilter(
"JPXDecode: JPEG 2000 image has no components".to_string(),
));
}
let num_components = comps.len();
let has_alpha = alpha_is_droppable(image.has_alpha(), num_components, declared_components);
let colour_components = if has_alpha { num_components - 1 } else { num_components };
if comps.iter().all(|c| c.samples().len() == npix) {
let mut samples = decoded.data_u8();
let mut opacity = None;
if has_alpha {
opacity = Some(
samples
.iter()
.skip(num_components - 1)
.step_by(num_components)
.copied()
.collect(),
);
samples = drop_last_channel(&samples, num_components);
}
return Ok(JpxImage {
samples,
num_components: colour_components as u8,
width,
height,
opacity,
});
}
decode_subsampled(comps, width, height, colour_components)
}
pub fn decode_jpx_indices(bytes: &[u8], highest_index: u8) -> Result<JpxImage> {
use hayro_jpeg2000::{DecodeSettings, DecoderContext, Image};
let settings = DecodeSettings {
resolve_palette_indices: false,
..DecodeSettings::default()
};
let image = Image::new(bytes, &settings)
.map_err(|e| Error::UnsupportedFilter(format!("JPXDecode: JPEG 2000 decode failed: {e:?}")))?;
let mut ctx = DecoderContext::default();
let decoded = image
.decode(&mut ctx)
.map_err(|e| Error::UnsupportedFilter(format!("JPXDecode: JPEG 2000 decode failed: {e:?}")))?;
let index_components = image.color_space().num_channels();
let [indices, ..] = decoded.components() else {
return Err(Error::UnsupportedFilter(
"JPXDecode: JPEG 2000 image has no components".to_string(),
));
};
if index_components != 1 {
return Err(Error::UnsupportedFilter(format!(
"JPXDecode: an /Indexed JPEG 2000 image needs one index component, found {index_components}"
)));
}
let (indices, clamped) = clamp_indices(indices.samples(), highest_index);
let opacity = codestream_opacity_component(bytes)
.and_then(|component| decoded.components().get(component))
.and_then(|component| {
let samples = component.samples();
(samples.len() == image.width() as usize * image.height() as usize).then(|| {
samples
.iter()
.map(|&value| value.round().clamp(0.0, 255.0) as u8)
.collect()
})
});
if clamped > 0 {
tracing::warn!(
clamped,
total = indices.len(),
highest_index,
"JPXDecode: clamped out-of-range palette indices to the palette"
);
}
Ok(JpxImage {
samples: indices,
num_components: 1,
width: image.width(),
height: image.height(),
opacity,
})
}
fn codestream_opacity_component(bytes: &[u8]) -> Option<usize> {
let header = find_box(bytes, b"jp2h")?;
let definitions = find_box(header, b"cdef")?;
let count = usize::from(u16::from_be_bytes(definitions.get(0..2)?.try_into().ok()?));
let entries = definitions.get(2..2 + count.checked_mul(6)?)?;
let channel = entries.chunks_exact(6).find_map(|entry| {
let channel = u16::from_be_bytes(entry[0..2].try_into().ok()?);
let kind = u16::from_be_bytes(entry[2..4].try_into().ok()?);
matches!(kind, 1 | 2).then_some(usize::from(channel))
})?;
let Some(mapping) = find_box(header, b"cmap") else {
return Some(channel);
};
let entry = mapping.get(channel.checked_mul(4)?..channel.checked_add(1)?.checked_mul(4)?)?;
Some(usize::from(u16::from_be_bytes(entry[0..2].try_into().ok()?)))
}
fn clamp_indices(samples: &[f32], highest_index: u8) -> (Vec<u8>, usize) {
let highest = f32::from(highest_index);
let mut clamped = 0;
let indices = samples
.iter()
.map(|&v| {
let rounded = v.round();
if !(0.0..=highest).contains(&rounded) {
clamped += 1;
}
rounded.clamp(0.0, highest) as u8
})
.collect();
(indices, clamped)
}
fn decode_subsampled(
comps: &[hayro_jpeg2000::ComponentData],
width: u32,
height: u32,
colour_components: usize,
) -> Result<JpxImage> {
let npix = width as usize * height as usize;
let (w, h) = (width as usize, height as usize);
let (sw, sh) = (width.div_ceil(2) as usize, height.div_ceil(2) as usize);
let mut planes: Vec<Vec<u8>> = Vec::with_capacity(comps.len());
for (ci, comp) in comps.iter().enumerate() {
if comp.bit_depth() != 8 {
return Err(Error::UnsupportedFilter(format!(
"JPXDecode: subsampled component {ci} with {}-bit depth not supported",
comp.bit_depth()
)));
}
let s = comp.samples();
let plane = if s.len() == npix {
s.iter().map(|&v| v.round().clamp(0.0, 255.0) as u8).collect()
} else if s.len() == sw * sh {
upsample_nearest_u8(s, sw, sh, w, h)
} else {
return Err(Error::UnsupportedFilter(format!(
"JPXDecode: subsampled component {ci} ({} samples) — only 2×2 (4:2:0) \
subsampling of a {width}×{height} image is supported",
s.len()
)));
};
planes.push(plane);
}
let mut samples = vec![0u8; npix * colour_components];
for (ci, plane) in planes.iter().take(colour_components).enumerate() {
for (i, &px) in plane.iter().enumerate() {
samples[i * colour_components + ci] = px;
}
}
Ok(JpxImage {
samples,
num_components: colour_components as u8,
width,
height,
opacity: planes.get(colour_components).cloned(),
})
}
fn drop_last_channel(samples: &[u8], stride: usize) -> Vec<u8> {
debug_assert!(stride > 1, "narrowing a single-channel buffer would leave nothing");
let kept = stride - 1;
let mut out = Vec::with_capacity(samples.len() / stride * kept);
for pixel in samples.chunks_exact(stride) {
out.extend_from_slice(&pixel[..kept]);
}
out
}
fn upsample_nearest_u8(sub: &[f32], sw: usize, sh: usize, fw: usize, fh: usize) -> Vec<u8> {
let mut out = vec![0u8; fw * fh];
for y in 0..fh {
let sy = (y * sh / fh).min(sh.saturating_sub(1));
for x in 0..fw {
let sx = (x * sw / fw).min(sw.saturating_sub(1));
out[y * fw + x] = sub[sy * sw + sx].round().clamp(0.0, 255.0) as u8;
}
}
out
}
#[cfg(test)]
mod tests {
use super::{alpha_is_droppable, decode_jpx, decode_jpx_indices, upsample_nearest_u8};
const SAMPLE_JP2: &[u8] = include_bytes!("../../tests/fixtures/jpx/sample_gray.jp2");
const RGBA_JP2: &[u8] = include_bytes!("../../tests/fixtures/jpx/gh1850_rgba.jp2");
const RGBA_J2K: &[u8] = include_bytes!("../../tests/fixtures/jpx/gh1850_rgba.j2k");
const GREY_ALPHA_JP2: &[u8] = include_bytes!("../../tests/fixtures/jpx/gh1850_grey_alpha.jp2");
const CMYK_QUADRANTS_J2K: &[u8] = include_bytes!("../../tests/fixtures/jpx/gh1855_cmyk_quadrants.j2k");
const PALETTE_CMYK_JP2: &[u8] = include_bytes!("../../tests/fixtures/jpx/gh1885_palette_cmyk.jp2");
const INDICES_GREY_JP2: &[u8] = include_bytes!("../../tests/fixtures/jpx/gh1885_indices_grey.jp2");
const PALETTE_ALPHA_JP2: &[u8] = include_bytes!("../../tests/fixtures/jpx/gh1885_palette_alpha.jp2");
const HIVAL15_LOSSY_JP2: &[u8] = include_bytes!("../../tests/fixtures/jpx/gh1885_hival15_lossy.jp2");
#[test]
fn a_lossy_palette_codestream_fails_when_the_decoder_resolves_its_palette() {
let err = decode_jpx(PALETTE_CMYK_JP2, Some(1))
.expect_err("negative control: the codestream palette lookup must fail on out-of-range indices");
assert!(
format!("{err:?}").contains("PaletteResolutionFailed"),
"unexpected failure: {err:?}"
);
}
#[test]
fn a_lossy_palette_codestream_decodes_to_clamped_indices() {
let indices = decode_jpx_indices(PALETTE_CMYK_JP2, u8::MAX)
.expect("GH#1885: the index plane must decode")
.samples;
assert_eq!(indices.len(), 120 * 40, "one index byte per pixel");
assert_eq!(indices[0], 255, "the top-left pixel is paper");
assert!(indices.contains(&0), "the ink index must survive the clamp");
}
#[test]
fn lossy_indices_round_and_clamp_samples_outside_the_palette() {
use hayro_jpeg2000::{DecodeSettings, DecoderContext, Image};
let settings = DecodeSettings {
resolve_palette_indices: false,
..DecodeSettings::default()
};
let image = Image::new(PALETTE_CMYK_JP2, &settings).expect("the index plane must parse");
let mut ctx = DecoderContext::default();
let decoded = image.decode(&mut ctx).expect("the index plane must decode");
let plane = decoded.components()[0].samples();
let below = plane.iter().filter(|&&v| v.round() < 0.0).count();
let above = plane.iter().filter(|&&v| v.round() > 255.0).count();
assert!(
below > 0 && above > 0,
"control failed: the lossy plane must ring past both ends, got {below} below and {above} above"
);
let rounds_up = plane
.iter()
.filter(|&&v| (0.0..255.0).contains(&v) && v.fract() > 0.5)
.count();
assert!(
rounds_up > 0,
"control failed: the plane must hold samples that round up"
);
let indices = decode_jpx_indices(PALETTE_CMYK_JP2, u8::MAX).expect("indices").samples;
for (&v, &index) in plane.iter().zip(&indices) {
let expected = if v.round() < 0.0 {
0
} else if v.round() > 255.0 {
255
} else {
v.round() as u8
};
assert_eq!(index, expected, "sample {v} must map to index {expected}");
}
assert_eq!(
super::clamp_indices(plane, u8::MAX).1,
below + above,
"every repaired sample is counted"
);
}
#[test]
fn a_16_bit_index_plane_reads_its_indices_unscaled() {
const INDICES_16BIT_JP2: &[u8] = include_bytes!("../../tests/fixtures/jpx/gh1885_indices_16bit.jp2");
let settings = hayro_jpeg2000::DecodeSettings {
resolve_palette_indices: false,
..hayro_jpeg2000::DecodeSettings::default()
};
let image = hayro_jpeg2000::Image::new(INDICES_16BIT_JP2, &settings).expect("the fixture must parse");
let mut ctx = hayro_jpeg2000::DecoderContext::default();
let decoded = image.decode(&mut ctx).expect("the fixture must decode");
assert_eq!(
decoded.components()[0].bit_depth(),
16,
"control failed: the fixture must be a 16-bit plane"
);
let indices = decode_jpx_indices(INDICES_16BIT_JP2, u8::MAX)
.expect("a 16-bit index plane must decode")
.samples;
assert_eq!(indices.len(), 16 * 16);
assert_eq!((indices[0], indices[15]), (3, 200), "the indices must not be rescaled");
}
#[test]
fn a_repaired_index_plane_logs_one_warning() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tracing_subscriber::layer::SubscriberExt as _;
struct WarnCount(Arc<AtomicUsize>);
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for WarnCount {
fn on_event(&self, event: &tracing::Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
if *event.metadata().level() == tracing::Level::WARN {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
}
let warnings = |bytes: &[u8]| {
let _second = tracing::Dispatch::new(tracing::subscriber::NoSubscriber::default());
let count = Arc::new(AtomicUsize::new(0));
let subscriber = tracing_subscriber::registry().with(WarnCount(Arc::clone(&count)));
tracing::subscriber::with_default(subscriber, || decode_jpx_indices(bytes, u8::MAX).expect("indices"));
count.load(Ordering::SeqCst)
};
assert_eq!(warnings(PALETTE_CMYK_JP2), 1, "the lossy plane needs the clamp");
assert_eq!(
warnings(INDICES_GREY_JP2),
0,
"control: a lossless plane needs no repair"
);
}
#[test]
fn clamp_indices_counts_the_samples_it_repairs() {
let (indices, clamped) = super::clamp_indices(&[-0.6, -0.4, 4.5, 15.4, 15.6, 300.0], 15);
assert_eq!(indices, vec![0, 0, 5, 15, 15, 15]);
assert_eq!(clamped, 3, "-0.6, 15.6 and 300.0 fall outside 0..=15");
}
#[test]
fn a_plain_codestream_decodes_to_its_own_samples_as_indices() {
let decoded = decode_jpx_indices(INDICES_GREY_JP2, u8::MAX).expect("a plain index codestream must decode");
assert_eq!(decoded.opacity, None, "an unmarked component must not become opacity");
let indices = decoded.samples;
let grey = decode_jpx(INDICES_GREY_JP2, None).expect("the same codestream as greyscale");
assert_eq!(indices, grey.samples);
assert!(
indices.contains(&0) && indices.contains(&255),
"the fixture must hold ink and paper"
);
}
#[test]
fn index_decoding_drops_alpha_and_refuses_colour_codestreams() {
let grey = decode_jpx_indices(GREY_ALPHA_JP2, u8::MAX)
.expect("grey plus alpha has one index component")
.samples;
assert_eq!(grey.len(), 16 * 16);
assert_eq!(grey[0], 180, "the left half's index must survive");
let err = decode_jpx_indices(RGBA_JP2, u8::MAX).expect_err("an RGBA codestream has three components");
assert!(format!("{err:?}").contains("found 3"), "unexpected failure: {err:?}");
}
#[test]
fn a_palette_codestream_with_an_opacity_channel_decodes_its_index_plane() {
let decoded = decode_jpx_indices(PALETTE_ALPHA_JP2, u8::MAX).expect("the index plane must decode");
let indices = decoded.samples;
let plain = decode_jpx_indices(INDICES_GREY_JP2, u8::MAX)
.expect("the same picture without alpha")
.samples;
assert_eq!(
indices, plain,
"the indices must be the plane's, not the opacity plane's"
);
assert_eq!(
decoded.opacity.as_deref(),
Some(vec![255; 120 * 40].as_slice()),
"the marked opacity plane must be retained beside the indices"
);
}
#[test]
fn lossy_indices_clamp_to_the_highest_palette_index() {
let unclamped = decode_jpx_indices(HIVAL15_LOSSY_JP2, u8::MAX)
.expect("the index plane must decode")
.samples;
assert!(
unclamped.iter().any(|&i| i > 15),
"control failed: the fixture must ring past index 15"
);
let indices = decode_jpx_indices(HIVAL15_LOSSY_JP2, 15)
.expect("the index plane must decode")
.samples;
assert_eq!(
indices.iter().max(),
Some(&15),
"no index may pass the palette's last entry"
);
assert!(indices.contains(&0), "the ink index must survive the clamp");
}
#[test]
fn alpha_is_droppable_truth_table() {
for (says, n, declared, expected) in [
(false, 4, None, false),
(true, 1, None, false),
(true, 4, Some(4), false),
(true, 4, Some(3), true),
(true, 4, None, true),
(true, 2, Some(1), true),
] {
assert_eq!(
alpha_is_droppable(says, n, declared),
expected,
"says={says} n={n} declared={declared:?}"
);
}
}
#[test]
fn a_bare_cmyk_codestream_keeps_four_components_whether_or_not_the_dictionary_declares_them() {
let raw = hayro_jpeg2000::Image::new(CMYK_QUADRANTS_J2K, &hayro_jpeg2000::DecodeSettings::default())
.expect("bare CMYK codestream must parse");
assert!(
raw.has_alpha(),
"negative control: upstream still invents an alpha channel for a bare codestream, which \
carries no cdef box to declare one -- if this fails, undeclared_component_count can go"
);
let undeclared = decode_jpx(CMYK_QUADRANTS_J2K, None).expect("bare CMYK codestream must decode");
assert_eq!(
undeclared.num_components, 4,
"with no declared colour space the Csiz of 4 must stand and the K plane must survive"
);
assert_eq!(
undeclared.samples.len(),
16 * 16 * 4,
"all four planes must be present, 16x16 image"
);
let declared =
decode_jpx(CMYK_QUADRANTS_J2K, Some(4)).expect("bare CMYK codestream must decode with a declared count");
assert_eq!(
declared.num_components, 4,
"a declared 4 must suppress the phantom alpha and keep all four planes"
);
assert_eq!(
declared.samples.len(),
16 * 16 * 4,
"all four planes must be present, 16x16 image"
);
}
#[test]
fn a_declared_count_that_disagrees_does_not_suppress_a_real_alpha_channel() {
for (label, bytes) in [("jp2", RGBA_JP2), ("j2k", RGBA_J2K)] {
let img = decode_jpx(bytes, Some(3)).unwrap_or_else(|e| panic!("{label} must decode: {e:?}"));
assert_eq!(
img.num_components, 3,
"{label}: a declared 3 against 4 actual components must still drop the alpha"
);
}
}
#[test]
fn rgba_codestream_reports_three_colour_components() {
let img = decode_jpx(RGBA_JP2, None).unwrap_or_else(|e| panic!("jp2 must decode: {e:?}"));
assert_eq!(
img.num_components, 3,
"the alpha channel must not be counted as a colour component"
);
assert_eq!(img.samples.len(), 16 * 16 * 3, "samples must be RGB-interleaved");
assert_eq!(&img.samples[..3], &[200, 100, 50], "first pixel must stay RGB");
}
#[test]
fn a_bare_four_component_codestream_with_nothing_declared_is_read_as_cmyk() {
let img = decode_jpx(RGBA_J2K, None).expect("bare four-component codestream must decode");
assert_eq!(
img.num_components, 4,
"a bare codestream cannot type a channel as alpha, so all four Csiz components are colour"
);
assert_eq!(img.samples.len(), 16 * 16 * 4, "all four planes must be interleaved");
}
#[test]
fn grey_plus_alpha_codestream_decodes_as_single_channel_grey() {
let img = decode_jpx(GREY_ALPHA_JP2, None).expect("grey+alpha must decode rather than be dropped");
assert_eq!(img.num_components, 1, "alpha must not be counted as a colour component");
assert_eq!(img.samples.len(), 16 * 16, "samples must be one channel per pixel");
assert_eq!(img.samples[0], 180, "the left half's grey value must survive");
}
const SUBSAMPLED_GREY_ALPHA_J2K: &[u8] =
include_bytes!("../../tests/fixtures/jpx/gh1902_subsampled_grey_alpha.j2k");
#[test]
fn a_subsampled_opacity_plane_is_returned_at_full_resolution() {
use hayro_jpeg2000::{DecodeSettings, DecoderContext, Image};
let image = Image::new(SUBSAMPLED_GREY_ALPHA_J2K, &DecodeSettings::default()).expect("fixture parses");
let mut ctx = DecoderContext::default();
let decoded = image.decode(&mut ctx).expect("fixture decodes");
let lens: Vec<usize> = decoded.components().iter().map(|c| c.samples().len()).collect();
assert_eq!(
lens,
vec![64, 64],
"hayro-jpeg2000 upsamples the subsampled plane itself"
);
let img = decode_jpx(SUBSAMPLED_GREY_ALPHA_J2K, Some(1)).expect("subsampled grey+alpha must decode");
assert_eq!(img.num_components, 1, "the opacity plane is not a colour component");
let grey: Vec<u8> = (0..8u8).flat_map(|y| (0..8u8).map(move |x| 16 * x + 2 * y)).collect();
assert_eq!(img.samples, grey, "the grey plane must be read as coded");
let opacity: Vec<u8> = (0..8usize)
.flat_map(|y| (0..8usize).map(move |x| 17 * (y / 2 * 4 + x / 2) as u8))
.collect();
assert_eq!(
img.opacity,
Some(opacity),
"the opacity plane must be upsampled, not dropped"
);
}
#[test]
fn codestream_component_count_reads_csiz_from_bare_and_jp2_boxed_streams() {
for (label, bytes, expected) in [
("bare cmyk j2k", CMYK_QUADRANTS_J2K, 4u8),
("bare rgba j2k", RGBA_J2K, 4),
("boxed rgba jp2", RGBA_JP2, 4),
("boxed grey+alpha jp2", GREY_ALPHA_JP2, 2),
("boxed grey jp2", SAMPLE_JP2, 1),
("bare subsampled grey+alpha j2k", SUBSAMPLED_GREY_ALPHA_J2K, 2),
] {
assert_eq!(
super::codestream_component_count(bytes),
Some(expected),
"{label}: Csiz must be read from the SIZ marker segment"
);
}
}
#[test]
fn undeclared_component_count_answers_only_for_a_bare_codestream() {
assert_eq!(super::undeclared_component_count(CMYK_QUADRANTS_J2K), Some(4));
assert_eq!(super::undeclared_component_count(RGBA_J2K), Some(4));
assert_eq!(
super::undeclared_component_count(RGBA_JP2),
None,
"a JP2-boxed stream carries colr/cdef, so its alpha answer must not be overridden"
);
}
#[test]
fn codestream_component_count_fails_closed_on_a_malformed_header() {
assert_eq!(super::codestream_component_count(&[]), None, "empty input");
assert_eq!(
super::codestream_component_count(&CMYK_QUADRANTS_J2K[..41]),
None,
"truncated one byte inside Csiz"
);
assert_eq!(
super::codestream_component_count(b"not a jpeg 2000 stream at all, forty-two bytes long"),
None,
"neither a SOC marker nor a JP2 box structure"
);
let mut no_siz = CMYK_QUADRANTS_J2K.to_vec();
no_siz[3] = 0x52;
assert_eq!(
super::codestream_component_count(&no_siz),
None,
"SOC not followed immediately by SIZ"
);
let mut bad_lsiz = CMYK_QUADRANTS_J2K.to_vec();
bad_lsiz[5] = bad_lsiz[5].wrapping_add(1);
assert_eq!(
super::codestream_component_count(&bad_lsiz),
None,
"Lsiz must equal 38 + 3 x Csiz or the header is not trusted"
);
}
#[test]
fn drop_last_channel_narrows_each_pixel() {
let rgba = [1u8, 2, 3, 4, 11, 12, 13, 14];
assert_eq!(super::drop_last_channel(&rgba, 4), vec![1, 2, 3, 11, 12, 13]);
let la = [7u8, 255, 9, 128];
assert_eq!(super::drop_last_channel(&la, 2), vec![7, 9]);
}
#[test]
fn upsample_nearest_2x2_to_4x4() {
let sub = [10.0f32, 20.0, 30.0, 40.0];
let out = upsample_nearest_u8(&sub, 2, 2, 4, 4);
assert_eq!(
out,
vec![10, 10, 20, 20, 10, 10, 20, 20, 30, 30, 40, 40, 30, 30, 40, 40,]
);
}
#[test]
fn upsample_nearest_2x2_to_3x3() {
let sub = [1.0f32, 2.0, 3.0, 4.0];
let out = upsample_nearest_u8(&sub, 2, 2, 3, 3);
assert_eq!(out.len(), 9);
assert_eq!(out[0], 1);
assert_eq!(out[8], 4);
}
#[test]
fn decode_jpx_grayscale() {
let img = decode_jpx(SAMPLE_JP2, None).expect("decode JP2 codestream");
assert_eq!(img.num_components, 1);
assert_eq!(img.samples.len(), 816 * 1056);
let first = img.samples[0];
assert!(
img.samples.iter().any(|&b| b != first),
"decoded image is uniformly flat — decode likely failed"
);
}
#[test]
fn codestream_palette_reads_the_pclr_box() {
let palette = super::codestream_palette(PALETTE_CMYK_JP2).expect("the fixture carries a pclr box");
assert_eq!(palette.columns, 4);
assert_eq!(palette.entries.len(), 256 * 4);
let with_alpha = super::codestream_palette(PALETTE_ALPHA_JP2).expect("an opacity channel maps directly");
assert_eq!(with_alpha.entries, palette.entries);
assert!(super::codestream_palette(INDICES_GREY_JP2).is_none());
assert!(super::codestream_palette(RGBA_J2K).is_none());
}
fn jp2_box(kind: &[u8; 4], contents: &[u8]) -> Vec<u8> {
let mut out = u32::try_from(8 + contents.len())
.expect("small box")
.to_be_bytes()
.to_vec();
out.extend_from_slice(kind);
out.extend_from_slice(contents);
out
}
fn palette_header(pclr: &[u8], cmap: &[u8], colr: Option<&[u8]>) -> Vec<u8> {
let mut header = Vec::new();
if let Some(colr) = colr {
header.extend(jp2_box(b"colr", colr));
}
header.extend(jp2_box(b"pclr", pclr));
header.extend(jp2_box(b"cmap", cmap));
let mut file = jp2_box(b"jP ", &[0x0D, 0x0A, 0x87, 0x0A]);
file.extend(jp2_box(b"jp2h", &header));
file
}
fn pclr(entries: u16, depths: &[u8]) -> Vec<u8> {
let mut out = entries.to_be_bytes().to_vec();
out.push(u8::try_from(depths.len()).expect("few columns"));
out.extend_from_slice(depths);
out.extend((0..usize::from(entries) * depths.len()).map(|i| i as u8));
out
}
const RGB_CMAP: [u8; 12] = [0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 1, 2];
const SRGB_COLR: [u8; 7] = [1, 0, 0, 0, 0, 0, 16];
fn read(pclr: &[u8], cmap: &[u8], colr: Option<&[u8]>) -> Option<(u8, Vec<u8>)> {
super::codestream_palette(&palette_header(pclr, cmap, colr)).map(|p| (p.columns, p.entries))
}
#[test]
fn codestream_palette_accepts_only_the_shape_it_can_resolve() {
let good = read(&pclr(2, &[7, 7, 7]), &RGB_CMAP, Some(&SRGB_COLR));
assert_eq!(
good,
Some((3, vec![0, 1, 2, 3, 4, 5])),
"control: an 8-bit sRGB palette"
);
assert_eq!(read(&pclr(2, &[15, 15, 15]), &RGB_CMAP, None), None, "16-bit entries");
assert_eq!(read(&pclr(2, &[0x87, 7, 7]), &RGB_CMAP, None), None, "signed entries");
assert_eq!(read(&pclr(2, &[7, 7]), &RGB_CMAP[..8], None), None, "two columns");
assert_eq!(read(&pclr(0, &[7, 7, 7]), &RGB_CMAP, None), None, "no entries");
assert_eq!(
read(&pclr(257, &[7, 7, 7]), &RGB_CMAP, None),
None,
"more entries than an index byte reaches"
);
assert!(
read(&pclr(256, &[7, 7, 7]), &RGB_CMAP, None).is_some(),
"control: 256 entries"
);
let short = pclr(2, &[7, 7, 7]);
assert_eq!(
read(&short[..short.len() - 1], &RGB_CMAP, None),
None,
"truncated entries"
);
}
#[test]
fn codestream_palette_requires_ordered_channel_maps() {
let swapped = [0, 0, 1, 1, 0, 0, 1, 0, 0, 0, 1, 2];
assert_eq!(read(&pclr(2, &[7, 7, 7]), &swapped, None), None, "columns out of order");
let other_component = [0, 1, 1, 0, 0, 1, 1, 1, 0, 1, 1, 2];
assert_eq!(
read(&pclr(2, &[7, 7, 7]), &other_component, None),
None,
"palette read from component 1"
);
let mut with_opacity = RGB_CMAP.to_vec();
with_opacity.extend_from_slice(&[0, 1, 0, 0]);
assert!(
read(&pclr(2, &[7, 7, 7]), &with_opacity, None).is_some(),
"control: a direct opacity channel"
);
let mut fourth_palette_channel = RGB_CMAP.to_vec();
fourth_palette_channel.extend_from_slice(&[0, 0, 1, 2]);
assert_eq!(
read(&pclr(2, &[7, 7, 7]), &fourth_palette_channel, None),
None,
"a fourth palette channel"
);
assert_eq!(read(&pclr(2, &[7, 7, 7]), &RGB_CMAP[..11], None), None, "a ragged cmap");
}
#[test]
fn codestream_palette_accepts_only_supported_colour_spaces() {
assert_eq!(
read(&pclr(2, &[7, 7, 7]), &RGB_CMAP, Some(&[1, 0, 0, 0, 0, 0, 18])),
None,
"sYCC"
);
assert_eq!(
read(&pclr(2, &[7, 7, 7]), &RGB_CMAP, Some(&[1, 0, 0, 0, 0, 0, 17])),
None,
"grey, three columns"
);
assert!(
read(&pclr(2, &[7]), &RGB_CMAP[..4], Some(&[1, 0, 0, 0, 0, 0, 17])).is_some(),
"control: grey"
);
assert!(
read(
&pclr(2, &[7, 7, 7, 7]),
&[RGB_CMAP.as_slice(), &[0, 0, 1, 3]].concat(),
Some(&[1, 0, 0, 0, 0, 0, 12])
)
.is_some(),
"control: CMYK"
);
assert!(
read(&pclr(2, &[7, 7, 7]), &RGB_CMAP, Some(&[2, 0, 0])).is_some(),
"an ICC colr is read by count"
);
}
#[test]
fn find_box_reads_open_ended_and_extended_lengths() {
let mut open_ended = jp2_box(b"jP ", &[0x0D, 0x0A, 0x87, 0x0A]);
open_ended.extend_from_slice(&[0, 0, 0, 0]);
open_ended.extend_from_slice(b"pclr");
open_ended.extend_from_slice(&[9, 8, 7]);
assert_eq!(super::find_box(&open_ended, b"pclr"), Some(&[9u8, 8, 7][..]));
let mut extended = vec![0, 0, 0, 1];
extended.extend_from_slice(b"pclr");
extended.extend_from_slice(&1000u64.to_be_bytes());
extended.extend_from_slice(&[5, 6]);
assert_eq!(super::find_box(&extended, b"pclr"), Some(&[5u8, 6][..]));
assert_eq!(super::find_box(&extended, b"cmap"), None);
}
}