feat(gif,image-codecs): add indexed/interlaced GIF, jpeg_progressive_scans, and JP2 decode (#1358)

* feat(gif,image-codecs): add indexed/interlaced GIF, jpeg_progressive_scans, and JP2 decode

* chore: update Cargo.lock for craft-image-codecs jp2 dep

* fix(image-codecs,svg): deref jp2 ch and re-export resvg from craft_svg::tree::draw
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-14 15:13:21 -05:00
1 parent d2b0bef7bf
commit 30209caad8
10 files changed
+322 -2

No files matched your search

Generated
+1
View File
@@ -1852,6 +1852,7 @@ dependencies = [
"exr",
"flate2",
"half",
"hayro-jpeg2000",
"image",
"image-webp",
"jpeg-encoder 0.6.1",
+52
View File
@@ -194,6 +194,45 @@ pub fn animation(width: u32, height: u32, frames: &[RgbaFrame<'_>], options: Opt
writer.finish()
}
/// Encode a single pre-quantized indexed-color frame as a GIF89a image.
///
/// `palette` holds 1 to 256 RGB triplets; `indices` is `width * height` row-major palette indices.
/// When `interlaced` is true, rows are reordered into GIF's 4-pass interlaced order before writing.
pub fn encode_indexed(
width: u32,
height: u32,
palette: &[[u8; 3]],
indices: &[u8],
transparent: Option<u8>,
interlaced: bool,
) -> Result<Vec<u8>, Error> {
let (w, h) = dimensions(width, height)?;
let expected = usize::from(w) * usize::from(h);
if indices.len() != expected {
return Err(Error::FrameLength { index: 0, actual: indices.len(), expected });
}
if palette.is_empty() || palette.len() > 256 {
return Err(Error::Encode("GIF palette must hold 1 to 256 entries".into()));
}
let flat_palette: Vec<u8> = palette.iter().flat_map(|c| *c).collect();
let buffer = if interlaced { std::borrow::Cow::Owned(interlace(indices, usize::from(w))) } else { std::borrow::Cow::Borrowed(indices) };
let mut enc = gif::Encoder::new(Vec::new(), w, h, &flat_palette).map_err(Error::from)?;
let frame = gif::Frame { width: w, height: h, buffer, transparent, interlaced, ..gif::Frame::default() };
enc.write_frame(&frame).map_err(Error::from)?;
enc.into_inner().map_err(Error::from)
}
/// The rows of a `width`-wide image in GIF's interlaced order: every 8th row from 0, every 8th
/// from 4, every 4th from 2, then every 2nd from 1.
fn interlace(px: &[u8], width: usize) -> Vec<u8> {
let rows: Vec<&[u8]> = px.chunks(width.max(1)).collect();
[(0, 8), (4, 8), (2, 4), (1, 2)]
.into_iter()
.flat_map(|(start, step)| rows.iter().skip(start).step_by(step))
.flat_map(|r| r.iter().copied())
.collect()
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
@@ -288,4 +327,17 @@ mod tests {
assert_eq!(decoded.frames[0].rgba, red);
assert_eq!(decoded.frames[1].rgba, blue);
}
#[test]
fn encode_indexed_validates_and_writes_gif() {
let palette = [[255, 0, 0], [0, 255, 0]];
let indices = [0u8, 1, 1, 0, 0, 1, 1, 0, 0];
let bytes = super::encode_indexed(3, 3, &palette, &indices, Some(1), true).unwrap();
let info = crate::scan(&bytes, 4).unwrap();
assert_eq!((info.width, info.height), (3, 3));
assert_eq!(info.frames.len(), 1);
assert_eq!(info.frames[0].transparent, Some(1));
assert!(super::encode_indexed(2, 2, &[], &[0; 4], None, false).is_err());
assert!(super::encode_indexed(2, 2, &palette, &[0; 3], None, false).is_err());
}
}
+1
View File
@@ -33,6 +33,7 @@ craft-jpeg = { workspace = true }
craft-metadata = { workspace = true }
zune-jpegxl = { version = "=0.5.2", default-features = false, features = ["std"] }
craft-heif = { workspace = true, optional = true }
hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["std", "simd"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rayon = { workspace = true }
+1
View File
@@ -10,6 +10,7 @@ pub(crate) mod gif_frames;
pub(crate) mod gif_palette;
pub(crate) mod heif;
pub(crate) mod iff;
pub(crate) mod jp2;
pub(crate) mod jpeg;
pub(crate) mod jxl;
pub(crate) mod pcx;
+242
View File
@@ -0,0 +1,242 @@
//! JPEG 2000 (JP2 container and raw J2K codestream) decoding via `hayro-jpeg2000`.
//!
//! Decodes within `Limits`, rejecting unsupported JPX multi-layer/composition boxes and
//! checking declared SIZ/tile/palette/channel-definition bounds before pixel allocation.
use hayro_jpeg2000::{ColorSpace, DecodeSettings, DecoderContext, Image as Jp2Image};
use crate::Format;
use crate::error::CodecError;
use crate::image::{ChannelLayout, Image, SampleType};
use crate::options::Limits;
const F: Format = Format::Jp2;
const JP2_SIGNATURE: &[u8] = b"\0\0\0\x0cjP \r\n\x87\n";
const MAX_PROFILE: usize = 4 * 1024 * 1024;
const MAX_TILES: u64 = 65_535;
const TILE_COMPONENT_BYTES: u64 = 256;
/// Boxes at one JP2 nesting level. Invalid lengths stop the walk at that level.
fn boxes(b: &[u8]) -> impl Iterator<Item = (&[u8], &[u8])> {
let mut at = 0usize;
std::iter::from_fn(move || {
let header = b.get(at..at.checked_add(8)?)?;
let size = u32::from_be_bytes(header.get(..4)?.try_into().ok()?);
let (length, head) = match size {
0 => (b.len().checked_sub(at)?, 8usize),
1 => (usize::try_from(u64::from_be_bytes(b.get(at.checked_add(8)?..at.checked_add(16)?)?.try_into().ok()?)).ok()?, 16usize),
n => (n as usize, 8usize),
};
if length < head {
return None;
}
let end = at.checked_add(length)?;
let data = b.get(at.checked_add(head)?..end)?;
let ty = header.get(4..8)?;
at = end;
Some((ty, data))
})
}
fn unsupported_jpx_content(bytes: &[u8]) -> bool {
fn association_has_content(data: &[u8], depth: usize) -> bool {
if depth >= 16 {
return true;
}
boxes(data).any(|(ty, child)| {
matches!(ty, b"jp2c" | b"jpch" | b"jplh" | b"comp" | b"ftbl") || (ty == b"asoc" && association_has_content(child, depth + 1))
})
}
let mut codestreams = 0u8;
for (ty, data) in boxes(bytes) {
match ty {
b"jp2c" => {
codestreams = codestreams.saturating_add(1);
if codestreams > 1 {
return true;
}
}
b"jpch" | b"jplh" | b"comp" | b"ftbl" => return true,
b"asoc" if association_has_content(data, 0) => return true,
_ => {}
}
}
false
}
fn codestream_size(bytes: &[u8]) -> Option<(u64, u32, u32, u64)> {
let stream = if bytes.starts_with(b"\xff\x4f") { bytes } else { boxes(bytes).find_map(|(ty, data)| (ty == b"jp2c").then_some(data))? };
if stream.get(..4)? != b"\xff\x4f\xff\x51" {
return None;
}
let length = u16::from_be_bytes(stream.get(4..6)?.try_into().ok()?) as usize;
let count = u16::from_be_bytes(stream.get(40..42)?.try_into().ok()?) as usize;
if count == 0 || length != 38usize.checked_add(count.checked_mul(3)?)? || stream.get(4..4usize.checked_add(length)?)?.len() != length {
return None;
}
let be32 = |range: std::ops::Range<usize>| stream.get(range).and_then(|v| v.try_into().ok()).map(u32::from_be_bytes);
let width = be32(8..12)?.checked_sub(be32(16..20)?)?;
let height = be32(12..16)?.checked_sub(be32(20..24)?)?;
let tiles = |grid: u32, offset: u32, tile: u32| match (grid.checked_sub(offset), tile) {
(Some(span), 1..) => Some(u64::from(span).div_ceil(u64::from(tile))),
_ => None,
};
let across = tiles(be32(8..12)?, be32(32..36)?, be32(24..28)?)?;
let down = tiles(be32(12..16)?, be32(36..40)?, be32(28..32)?)?;
Some((count as u64, width, height, across.checked_mul(down)?))
}
fn single_header_boxes(bytes: &[u8]) -> bool {
let mut headers = boxes(bytes).filter(|(ty, _)| *ty == b"jp2h");
let (Some((_, header)), None) = (headers.next(), headers.next()) else {
return false;
};
[b"pclr", b"cmap", b"cdef"].iter().all(|kind| boxes(header).filter(|(ty, _)| ty == kind).count() <= 1)
}
fn channel_definitions(bytes: &[u8]) -> Option<u64> {
if bytes.starts_with(b"\xff\x4f") {
return None;
}
let header = boxes(bytes).find_map(|(ty, data)| (ty == b"jp2h").then_some(data))?;
let cdef = boxes(header).find_map(|(ty, data)| (ty == b"cdef").then_some(data))?;
let count = cdef.get(..2).and_then(|n| n.try_into().ok()).map_or(0, |n: [u8; 2]| usize::from(u16::from_be_bytes(n)));
let Some(entries) = cdef.get(2..).filter(|rest| count > 0 && rest.len() == count * 6) else {
return Some(0);
};
let mut defs: Vec<[u16; 3]> =
entries.as_chunks::<6>().0.iter().map(|&[a, b, c, d, e, f]| [[a, b], [c, d], [e, f]].map(u16::from_be_bytes)).collect();
defs.sort_unstable();
let opacity = defs.last().is_some_and(|&[_, kind, association]| kind == 1 && association == 0);
let colors = defs.len() - usize::from(opacity);
let mut seen = vec![false; colors];
let layout_ok = defs.iter().enumerate().all(|(i, &[channel, kind, association])| {
usize::from(channel) == i
&& (i >= colors
|| (kind == 0
&& usize::from(association).checked_sub(1).and_then(|slot| seen.get_mut(slot)).is_some_and(|s| !std::mem::replace(s, true))))
});
Some(if layout_ok { defs.len() as u64 } else { 0 })
}
fn palette_columns(bytes: &[u8], components: u64) -> Result<Option<u64>, ()> {
if bytes.starts_with(b"\xff\x4f") {
return Ok(None);
}
let Some(header) = boxes(bytes).find_map(|(ty, data)| (ty == b"jp2h").then_some(data)) else {
return Ok(None);
};
let Some(palette) = boxes(header).find_map(|(ty, data)| (ty == b"pclr").then_some(data)) else {
return Ok(None);
};
let columns = palette.get(2).copied().ok_or(())?;
let depths = palette.get(3..3 + usize::from(columns)).ok_or(())?;
if columns == 0 || depths.iter().any(|&d| d & 0x80 != 0 || (d & 0x7f) + 1 > 31) {
return Err(());
}
let Some(mapping) = boxes(header).find_map(|(ty, data)| (ty == b"cmap").then_some(data)) else {
return Ok(Some(u64::from(columns)));
};
if mapping.len() != usize::from(columns) * 4 {
return Err(());
}
for &[c0, c1, kind, column] in mapping.as_chunks::<4>().0 {
let valid = u64::from(u16::from_be_bytes([c0, c1])) < components
&& match kind {
0 => true,
1 => column < columns,
_ => false,
};
if !valid {
return Err(());
}
}
Ok(Some(u64::from(columns)))
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let malformed = |msg: &str| CodecError::malformed(F, msg);
if bytes.starts_with(JP2_SIGNATURE) && unsupported_jpx_content(bytes) {
return Err(CodecError::unsupported(F, "unsupported color space or JPX content"));
}
if bytes.starts_with(JP2_SIGNATURE) && !single_header_boxes(bytes) {
return Err(malformed("invalid JP2 header boxes"));
}
let (codestream_channels, declared_w, declared_h, tile_count) = codestream_size(bytes).ok_or_else(|| malformed("invalid codestream SIZ"))?;
if tile_count == 0 || tile_count > MAX_TILES {
return Err(malformed("invalid tile count"));
}
let max_alloc = limits.max_alloc;
let declared_pixels = u64::from(declared_w).checked_mul(u64::from(declared_h));
if declared_w == 0
|| declared_h == 0
|| declared_w > limits.max_width
|| declared_h > limits.max_height
|| declared_pixels.is_none_or(|n| n > limits.max_pixels)
|| declared_pixels.and_then(|n| n.checked_mul(codestream_channels)).and_then(|n| n.checked_mul(4)).is_none_or(|n| n > max_alloc)
|| tile_count.checked_mul(codestream_channels).and_then(|n| n.checked_mul(TILE_COMPONENT_BYTES)).is_none_or(|n| n > max_alloc)
{
return Err(CodecError::LimitExceeded(format!("dimensions {declared_w}x{declared_h} exceed decode limits")));
}
let header = boxes(bytes).find_map(|(ty, data)| (ty == b"jp2h").then_some(data)).unwrap_or_default();
if boxes(header).any(|(ty, data)| ty == b"colr" && data.len() > MAX_PROFILE + 3) {
return Err(CodecError::unsupported(F, "ICC profile exceeds 4 MiB limit"));
}
let palette = palette_columns(bytes, codestream_channels).map_err(|()| malformed("invalid JP2 palette"))?;
let palette_rows = boxes(header).find_map(|(ty, data)| (ty == b"pclr").then(|| data.get(..2)).flatten());
if let (Some(columns), Some(&[r0, r1])) = (palette, palette_rows)
&& u64::from(u16::from_be_bytes([r0, r1])) * (columns * 8 + 24) > max_alloc
{
return Err(CodecError::LimitExceeded("JP2 palette exceeds max_alloc".into()));
}
let image = Jp2Image::new(bytes, &DecodeSettings::default()).map_err(|e| malformed(&format!("{e:?}")))?;
let (w, h) = (image.width(), image.height());
let color = image.color_space();
let (channels, icc) = match color {
ColorSpace::Gray => (1u8, None),
ColorSpace::RGB => (3u8, None),
ColorSpace::CMYK => (4u8, None),
ColorSpace::Icc { num_channels: ch @ (1 | 3 | 4), profile } if profile.len() <= MAX_PROFILE => (*ch, Some(profile.to_vec())),
_ => return Err(CodecError::unsupported(F, "unsupported JPEG 2000 color space")),
};
let has_alpha = image.has_alpha();
let stride = u64::from(channels) + u64::from(has_alpha);
if palette.is_some_and(|columns| columns != stride) {
return Err(malformed("JP2 palette columns do not match output channels"));
}
if channel_definitions(bytes).is_some_and(|count| count != stride || count != palette.unwrap_or(codestream_channels)) {
return Err(malformed("JP2 channel definitions do not match output channels"));
}
let layout = match (channels, has_alpha) {
(1, false) => ChannelLayout::Gray,
(1, true) => ChannelLayout::GrayA,
(3, false) => ChannelLayout::Rgb,
(3, true) => ChannelLayout::Rgba,
(4, false) => ChannelLayout::Cmyk,
(4, true) => ChannelLayout::CmykA,
_ => return Err(CodecError::unsupported(F, "unsupported channel count")),
};
limits.check(w, h, layout, SampleType::U8)?;
let plane_channels = codestream_channels.max(stride).max(palette.unwrap_or(0));
let pixels = u64::from(w).checked_mul(u64::from(h)).ok_or_else(|| CodecError::LimitExceeded("pixel count overflow".into()))?;
let size = pixels.checked_mul(stride).ok_or_else(|| CodecError::LimitExceeded("sample count overflow".into()))?;
let plane_bytes =
pixels.checked_mul(plane_channels).and_then(|n| n.checked_mul(4)).ok_or_else(|| CodecError::LimitExceeded("plane overflow".into()))?;
let scratch_bytes = plane_bytes
.checked_mul(2)
.and_then(|n| n.checked_add(pixels.checked_mul(8)?))
.ok_or_else(|| CodecError::LimitExceeded("scratch overflow".into()))?;
let working = size
.checked_add(plane_bytes)
.and_then(|n| n.checked_add(scratch_bytes))
.ok_or_else(|| CodecError::LimitExceeded("memory overflow".into()))?;
if working > max_alloc {
return Err(CodecError::LimitExceeded(format!("working set {working} exceeds max_alloc {max_alloc}")));
}
let len = usize::try_from(size).map_err(|_| CodecError::LimitExceeded("buffer does not fit in memory".into()))?;
let mut samples = vec![0u8; len];
image.decode_into(&mut samples, &mut DecoderContext::default()).map_err(|e| malformed(&format!("{e:?}")))?;
let mut out = Image::from_raw(w, h, layout, SampleType::U8, samples)?;
out.icc = icc;
Ok(out)
}
+3
View File
@@ -479,6 +479,9 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
// Optimized keeps the standard tables until that decoder is updated. Progressive is fine.
let progressive = opts.jpeg_format == JpegFormat::Progressive;
enc.set_progressive(progressive);
if progressive && let Some(scans) = opts.jpeg_progressive_scans {
enc.set_progressive_scans(scans);
}
let optimized = opts.jpeg_format == JpegFormat::Optimized && !subsampled;
enc.set_optimized_huffman_tables(progressive || optimized);
let e = |e: jpeg_encoder::EncodingError| CodecError::encode(F, e);
+13 -1
View File
@@ -42,6 +42,8 @@ pub enum Format {
Dicom,
/// Amiga IFF/ILBM (`FORM ... ILBM`): planar bitplanes with a palette or 24-bit RGB.
Iff,
/// JPEG 2000 (JP2 container or raw J2K codestream): read-only; see [`ASYMMETRIC_EXCEPTIONS`].
Jp2,
}
/// What a format can hold **and** what this crate reads/writes for it.
@@ -98,6 +100,7 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
writing needs an HEVC encoder, and the mature ones (x265, libheif) are C, so write stays \
unsupported. Without the feature HEIF is detected but neither read nor written.",
),
(Format::Jp2, "JPEG 2000 decode uses hayro-jpeg2000 (pure Rust); writing JPEG 2000 is not provided."),
];
use ChannelLayout as L;
@@ -110,9 +113,10 @@ const TIFF_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::
impl Format {
/// Every format known to the crate (enabled or not).
pub const ALL: [Format; 23] = [
pub const ALL: [Format; 24] = [
Format::Png,
Format::Jpeg,
Format::Jp2,
Format::Jxl,
Format::Tiff,
Format::WebP,
@@ -171,6 +175,7 @@ impl Format {
Format::Cineon => "Cineon",
Format::Dicom => "DICOM",
Format::Iff => "Amiga IFF",
Format::Jp2 => "JPEG 2000",
}
}
@@ -201,6 +206,7 @@ impl Format {
Format::Cineon => &["cin"],
Format::Dicom => &["dcm", "dicom", "dc3"],
Format::Iff => &["iff", "lbm", "ilbm"],
Format::Jp2 => &["jp2", "j2k", "j2c", "jpf", "jpx"],
}
}
@@ -229,6 +235,7 @@ impl Format {
Format::Cineon => "image/x-cin",
Format::Dicom => "application/dicom",
Format::Iff => "image/x-iff",
Format::Jp2 => "image/jp2",
}
}
@@ -303,6 +310,7 @@ pub fn caps(format: Format) -> FormatCaps {
Format::Cineon => FormatCaps { depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
Format::Dicom => FormatCaps { read: true, write: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
Format::Iff => FormatCaps { depths: &[S::U8], layouts: &[L::Rgb, L::Rgba], alpha: true, ..base },
Format::Jp2 => FormatCaps { read: true, write: false, layouts: &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::Cmyk, L::CmykA], icc: true, ..base },
}
}
@@ -320,6 +328,10 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
if b.starts_with(&[0xFF, 0x0A]) || b.starts_with(b"\0\0\0\x0cJXL \r\n\x87\n") {
return Some(Format::Jxl);
}
// JPEG 2000 container signature box or raw J2K codestream (SOC + SIZ).
if b.starts_with(b"\0\0\0\x0cjP \r\n\x87\n") || b.starts_with(b"\xff\x4f\xff\x51") {
return Some(Format::Jp2);
}
if b.starts_with(b"II*\0") || b.starts_with(b"MM\0*") || b.starts_with(b"II+\0") || b.starts_with(b"MM\0+") {
return Some(Format::Tiff);
}
+3 -1
View File
@@ -61,7 +61,7 @@ pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_
pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding};
pub use half::f16;
use crate::codecs::{dds, dicom, dpx, exr, fits, heif, iff, jpeg, jxl, pcx, png, pnm, tiff, via_image, webp, xpm};
use crate::codecs::{dds, dicom, dpx, exr, fits, heif, iff, jp2, jpeg, jxl, pcx, png, pnm, tiff, via_image, webp, xpm};
/// Detect the format and decode with default [`Limits`].
pub fn decode(bytes: &[u8]) -> Result<Image, CodecError> {
@@ -102,6 +102,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
Format::Iff => iff::decode(bytes, l),
Format::OpenExr => exr::decode(bytes, l),
Format::Heif => heif::decode(bytes, l, opts.keep_orientation),
Format::Jp2 => jp2::decode(bytes, l),
Format::Jxl => Err(CodecError::unsupported(format, "JPEG XL reading is not yet available; export is supported")),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l),
}?;
@@ -446,6 +447,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
Format::Iff => iff::encode(image, plan),
Format::OpenExr => exr::encode(image, plan, opts),
Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")),
Format::Jp2 => Err(CodecError::unsupported(format, "JPEG 2000 is import-only; save as PNG or TIFF instead")),
Format::Jxl => jxl::encode(image, plan),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => {
via_image::encode(format, image, plan, opts)
+4
View File
@@ -299,6 +299,9 @@ pub struct EncodeOptions {
pub jpeg_chroma_subsampling: bool,
/// JPEG scan layout (baseline, baseline optimized or progressive).
pub jpeg_format: JpegFormat,
/// Optional number of progressive JPEG scans (typically 3..=5). Ignored unless
/// `jpeg_format` is [`JpegFormat::Progressive`].
pub jpeg_progressive_scans: Option<u8>,
pub png_compression: PngCompression,
/// Write Adam7-interlaced PNG.
pub png_interlaced: bool,
@@ -336,6 +339,7 @@ impl Default for EncodeOptions {
jpeg_quality: 90,
jpeg_chroma_subsampling: true,
jpeg_format: JpegFormat::Baseline,
jpeg_progressive_scans: None,
png_compression: PngCompression::Default,
png_interlaced: false,
png_stereo: None,
+2
View File
@@ -24,6 +24,8 @@ use std::collections::HashMap;
use usvg::Transform;
#[cfg(feature = "tree-render")]
pub use resvg;
#[cfg(feature = "tree-render")]
pub use resvg::tiny_skia;