From 77591e5efbfef912cd433d5d17c0363c237812d2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 18:53:16 -0500 Subject: [PATCH] craft-gif: GIF block reader (#939) One walk over a GIF's blocks, without decompressing pixels: the logical screen, each frame's position, delay, disposal and transparent colour, the loop count and the first frame's colour table. Replaces PhotoCraft's two block walkers and gives the other apps a header-only animation probe. --- Cargo.lock | 7 ++ Cargo.toml | 1 + README.md | 4 + crates/gif/Cargo.toml | 15 +++ crates/gif/src/lib.rs | 209 ++++++++++++++++++++++++++++++++++++++++ crates/gif/src/tests.rs | 164 +++++++++++++++++++++++++++++++ 6 files changed, 400 insertions(+) create mode 100644 crates/gif/Cargo.toml create mode 100644 crates/gif/src/lib.rs create mode 100644 crates/gif/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 36919285..e9b596a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1128,6 +1128,13 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-gif" +version = "0.1.0" +dependencies = [ + "image", +] + [[package]] name = "craft-hdr" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b77c6582..5738359d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ craft-font-manager = { path = "crates/font-manager" } craft-font-store = { path = "crates/font-store" } craft-gainmap = { path = "crates/gainmap" } craft-gcode = { path = "crates/gcode" } +craft-gif = { path = "crates/gif" } craft-hdr = { path = "crates/hdr" } craft-mesh = { path = "crates/mesh" } craft-heif = { path = "crates/heif" } diff --git a/README.md b/README.md index 87ccc785..3695e031 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = ", + /// Disposal method (0–7) from the Graphic Control Extension. + pub disposal: u8, + /// Whether the frame has its own colour table. + pub local_table: bool, +} + +/// What the blocks of a GIF say. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Info { + /// Logical screen size. + pub width: u16, + pub height: u16, + /// Whether the file has a Global Color Table. + pub global_table: bool, + /// The frames found, in file order, up to the caller's limit. + pub frames: Vec, + /// Whether the walk reached the trailer. When it did not (the file is cut short or damaged, or + /// the frame limit was reached), more frames may exist than [`Info::frames`] holds. + pub complete: bool, + /// The NETSCAPE2.0 loop count: `Some(0)` loops forever, `None` when the file has none (play once). + pub loop_count: Option, +} + +impl Info { + /// Whether the file is known to hold more than one frame, or `None` when the walk stopped + /// before the trailer after at most one frame, so frames past the break could not be counted. + pub fn has_more_frames(&self) -> Option { + if self.frames.len() > 1 { Some(true) } else { self.complete.then_some(false) } + } + + /// The sum of the frame delays in hundredths of a second, as stored. + pub fn total_delay_cs(&self) -> u64 { + self.frames.iter().map(|f| u64::from(f.delay_cs)).sum() + } +} + +/// Whether `bytes` starts with a GIF signature. +pub fn sniff(bytes: &[u8]) -> bool { + bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") +} + +/// Bytes of the colour table a packed-fields byte announces (0 without one). +fn table_len(packed: u8) -> usize { + if packed & 0x80 != 0 { 3usize << ((packed & 7) + 1) } else { 0 } +} + +fn u16_at(b: &[u8], at: usize) -> Option { + Some(u16::from_le_bytes(b.get(at..at.checked_add(2)?)?.try_into().ok()?)) +} + +/// Skips the data sub-blocks starting at `at` (each length-prefixed, ended by a zero length). +fn skip_sub_blocks(b: &[u8], mut at: usize) -> Option { + loop { + let n = usize::from(*b.get(at)?); + at = at.checked_add(1 + n)?; + if n == 0 { + return Some(at); + } + } +} + +/// Walks the blocks of a GIF without decompressing any, recording at most `max_frames` frames. +/// `None` when `bytes` is not a GIF or ends inside its Logical Screen Descriptor. A file that +/// breaks off later gives what was read, with [`Info::complete`] false. +pub fn scan(bytes: &[u8], max_frames: usize) -> Option { + let b = bytes; + if !sniff(b) { + return None; + } + // Logical Screen Descriptor: width, height, packed fields, background, aspect. + let (width, height, packed) = (u16_at(b, 6)?, u16_at(b, 8)?, *b.get(10)?); + b.get(12)?; + let mut info = Info { width, height, global_table: packed & 0x80 != 0, frames: Vec::new(), complete: false, loop_count: None }; + let mut at = 13 + table_len(packed); + // What the last Graphic Control Extension said, for the next image. + let (mut delay_cs, mut transparent, mut disposal) = (0u16, None, 0u8); + // Each step either finishes the walk or moves `at` forward, so this ends with the file. + let mut step = || -> Option { + match *b.get(at)? { + // Extension: label, then data sub-blocks up to a zero-length one. + 0x21 => { + let label = *b.get(at + 1)?; + let body = at + 2; + // Graphic Control Extension: block size 4, packed (bit 0: transparent colour + // given; bits 2–4: disposal), delay (2 bytes), transparent colour index. + if label == 0xF9 && *b.get(body)? == 4 { + let flags = *b.get(body + 1)?; + delay_cs = u16_at(b, body + 2)?; + transparent = (flags & 0x01 != 0).then_some(*b.get(body + 4)?); + disposal = (flags >> 2) & 7; + } + // Application Extension NETSCAPE2.0 (or ANIMEXTS1.0): sub-block 3, 1, loop count. + if label == 0xFF + && matches!(b.get(body..body + 12), Some([11, id @ ..]) if id == b"NETSCAPE2.0" || id == b"ANIMEXTS1.0") + && b.get(body + 12..body + 14) == Some(&[3, 1]) + { + info.loop_count = u16_at(b, body + 14); + } + at = skip_sub_blocks(b, body)?; + Some(true) + } + // Image Descriptor: separator, left, top, width, height, packed fields, then the + // Local Color Table if bit 7 is set, the LZW code size and the data sub-blocks. + 0x2C => { + if info.frames.len() >= max_frames { + return None; + } + let packed = *b.get(at + 9)?; + info.frames.push(Frame { + offset: at, + left: u16_at(b, at + 1)?, + top: u16_at(b, at + 3)?, + width: u16_at(b, at + 5)?, + height: u16_at(b, at + 7)?, + delay_cs, + transparent, + disposal, + local_table: packed & 0x80 != 0, + }); + (delay_cs, transparent, disposal) = (0, None, 0); + at = skip_sub_blocks(b, (at + 10).checked_add(table_len(packed))?.checked_add(1)?)?; + Some(true) + } + // Trailer. + 0x3B => { + info.complete = true; + Some(false) + } + // Anything else: the blocks break off here. + _ => None, + } + }; + while step() == Some(true) {} + Some(info) +} + +/// The colours of the table the first frame uses (its Local Color Table when it has one, else +/// the Global one) and its transparent entry. `None` for anything that is not a well-formed GIF +/// up to its first image, or a first frame without a table. A transparent index past the table +/// is no transparent entry. +pub fn palette(bytes: &[u8]) -> Option<(Vec<[u8; 3]>, Option)> { + let info = scan(bytes, 1)?; + let first = info.frames.first()?; + let (at, packed) = if first.local_table { (first.offset + 10, *bytes.get(first.offset + 9)?) } else { (13, *bytes.get(10)?) }; + let colors = bytes.get(at..at.checked_add(table_len(packed))?)?.as_chunks::<3>().0.to_vec(); + if colors.is_empty() { + return None; + } + let transparent = first.transparent.filter(|i| usize::from(*i) < colors.len()); + Some((colors, transparent)) +} + +/// Whether a file of `file_len` bytes can plausibly hold `declared_bytes` of decoded picture: at +/// most `max_ratio` decoded bytes for each file byte, plus 1 MiB. A decoder that sets up the whole +/// logical screen before it reads a frame should be given only files that pass this. +pub fn plausible(declared_bytes: u64, file_len: usize, max_ratio: u64) -> bool { + declared_bytes <= (file_len as u64).saturating_mul(max_ratio).saturating_add(1 << 20) +} + +impl Info { + /// The decoded bytes the file must plausibly back at `bytes_per_pixel`: the first frame, and + /// whatever of the logical screen exceeds `unbacked_canvas` bytes (the screen around the first + /// frame needs no data, so that much of it is free). Use with [`plausible`]. + pub fn bytes_to_back(&self, bytes_per_pixel: u64, unbacked_canvas: u64) -> u64 { + let screen = u64::from(self.width).saturating_mul(u64::from(self.height)).saturating_mul(bytes_per_pixel); + match self.frames.first() { + Some(f) => { + u64::from(f.width).saturating_mul(u64::from(f.height)).saturating_mul(bytes_per_pixel).max(screen.saturating_sub(unbacked_canvas)) + } + None => screen, + } + } +} diff --git a/crates/gif/src/tests.rs b/crates/gif/src/tests.rs new file mode 100644 index 00000000..a75743b1 --- /dev/null +++ b/crates/gif/src/tests.rs @@ -0,0 +1,164 @@ +//! The palette tests are PhotoCraft's; the scan is also checked against files written by the +//! `image` crate's GIF encoder. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +/// A GIF up to its first image: `global` table entries, optional GCE transparency, optional +/// local table. +fn gif(global: &[[u8; 3]], transparent: Option, local: Option<&[[u8; 3]]>) -> Vec { + let size_bits = |n: usize| (n.max(2).next_power_of_two().trailing_zeros() - 1) as u8; + let mut b = b"GIF89a".to_vec(); + b.extend([4, 0, 4, 0]); + b.push(if global.is_empty() { 0 } else { 0x80 | size_bits(global.len()) }); + b.extend([0, 0]); + let pad = |b: &mut Vec, t: &[[u8; 3]]| { + let n = 2usize << size_bits(t.len()); + for k in 0..n { + b.extend(t.get(k).copied().unwrap_or([0, 0, 0])); + } + }; + if !global.is_empty() { + pad(&mut b, global); + } + // A comment extension first, to skip. + b.extend([0x21, 0xFE, 3, b'h', b'i', b'!', 0]); + if let Some(i) = transparent { + b.extend([0x21, 0xF9, 4, 0x01, 0, 0, i, 0]); + } + b.extend([0x2C, 0, 0, 0, 0, 4, 0, 4, 0]); + match local { + Some(t) => { + b.push(0x80 | size_bits(t.len())); + pad(&mut b, t); + } + None => b.push(0), + } + b.extend([2, 2, 0x4C, 0x01, 0, 0x3B]); + b +} + +/// An animated GIF of solid 2 × 2 frames with these delays (hundredths), written by `image`. +fn animated(delays_cs: &[u32], repeat: Option) -> Vec { + let mut out = Vec::new(); + { + let mut enc = image::codecs::gif::GifEncoder::new(&mut out); + if let Some(r) = repeat { + enc.set_repeat(r).unwrap(); + } + for (i, &cs) in delays_cs.iter().enumerate() { + let buf = image::RgbaImage::from_pixel(2, 2, image::Rgba([i as u8 * 40, 0, 0, 255])); + enc.encode_frame(image::Frame::from_parts(buf, 0, 0, image::Delay::from_numer_denom_ms(cs * 10, 1))).unwrap(); + } + } + out +} + +#[test] +fn the_first_frames_table_and_transparent_entry() { + let g = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255]]; + assert_eq!(palette(&gif(&g, None, None)), Some((g.to_vec(), None))); + assert_eq!(palette(&gif(&g, Some(2), None)), Some((g.to_vec(), Some(2)))); + // A local table wins over the global one. + let l = [[1, 2, 3], [4, 5, 6]]; + assert_eq!(palette(&gif(&g, Some(1), Some(&l))), Some((l.to_vec(), Some(1)))); + // A transparent index past the table is no transparent entry. + assert_eq!(palette(&gif(&l, Some(9), None)), Some((l.to_vec(), None))); +} + +#[test] +fn no_table_or_not_a_gif_is_none() { + assert_eq!(palette(&gif(&[], None, None)), None, "no table at all"); + assert_eq!(palette(b"\x89PNG\r\n\x1a\n"), None); + assert_eq!(palette(b""), None); + assert_eq!(scan(b"\x89PNG\r\n\x1a\n", 10), None); + assert_eq!(scan(b"GIF89a\x01\x00", 10), None); + assert!(sniff(b"GIF87a") && sniff(b"GIF89a...") && !sniff(b"GIF88a")); +} + +#[test] +fn a_scan_reports_the_screen_and_the_frame() { + let g = [[9, 9, 9], [8, 8, 8]]; + let info = scan(&gif(&g, Some(1), None), 10).unwrap(); + assert_eq!((info.width, info.height, info.global_table, info.complete, info.loop_count), (4, 4, true, true, None)); + let [f] = info.frames.as_slice() else { panic!("{:?}", info.frames) }; + assert_eq!((f.left, f.top, f.width, f.height, f.delay_cs, f.transparent, f.disposal, f.local_table), (0, 0, 4, 4, 0, Some(1), 0, false)); + assert_eq!(info.has_more_frames(), Some(false)); + let info = scan(&gif(&g, None, Some(&g)), 10).unwrap(); + assert_eq!((info.frames[0].local_table, info.frames[0].transparent), (true, None)); +} + +#[test] +fn an_animation_reports_every_delay_and_the_loop_count() { + use image::codecs::gif::Repeat; + let bytes = animated(&[5, 10, 0, 7], Some(Repeat::Infinite)); + let info = scan(&bytes, 100).unwrap(); + assert_eq!((info.width, info.height, info.complete, info.loop_count), (2, 2, true, Some(0))); + assert_eq!(info.frames.iter().map(|f| f.delay_cs).collect::>(), [5, 10, 0, 7]); + assert_eq!((info.total_delay_cs(), info.has_more_frames()), (22, Some(true))); + assert!(info.frames.iter().all(|f| (f.width, f.height) == (2, 2))); + assert_eq!(scan(&animated(&[5, 5], Some(Repeat::Finite(3))), 100).unwrap().loop_count, Some(3)); + assert_eq!(scan(&animated(&[5], None), 100).unwrap().has_more_frames(), Some(false)); +} + +#[test] +fn the_frame_limit_stops_the_walk() { + let bytes = animated(&[1, 2, 3, 4, 5], None); + let info = scan(&bytes, 3).unwrap(); + assert_eq!((info.frames.len(), info.complete, info.has_more_frames()), (3, false, Some(true))); + let info = scan(&bytes, 5).unwrap(); + assert_eq!((info.frames.len(), info.complete), (5, true)); + // One frame recorded and the walk stopped: whether more exist is not known. + let info = scan(&bytes, 1).unwrap(); + assert_eq!((info.frames.len(), info.has_more_frames()), (1, None)); + assert_eq!(scan(&bytes, 0).unwrap().frames.len(), 0); +} + +#[test] +fn a_cut_or_damaged_file_gives_what_was_read() { + let bytes = animated(&[4, 4, 4], None); + let whole = scan(&bytes, 100).unwrap(); + for cut in 0..bytes.len() { + // Never a panic; never complete; never more frames than the whole file has. + if let Some(info) = scan(&bytes[..cut], 100) { + assert!(!info.complete && info.frames.len() <= whole.frames.len(), "cut {cut}"); + } + let _ = palette(&bytes[..cut]); + } + for i in 0..bytes.len() { + for v in [0u8, 0x21, 0x2C, 0x3B, 0xFF] { + let mut b = bytes.clone(); + b[i] = v; + if let Some(info) = scan(&b, 100) { + assert!(info.frames.iter().all(|f| f.offset < b.len())); + } + let _ = palette(&b); + } + } + // A cut inside the second frame keeps the first. + let second = whole.frames[1].offset; + let info = scan(&bytes[..second + 5], 100).unwrap(); + assert_eq!((info.frames.len(), info.complete, info.has_more_frames()), (1, false, None)); +} + +#[test] +fn plausibility_of_a_screen_for_a_file_size() { + // A 20-byte file declaring a 65535 × 65535 screen around a 1 × 1 frame. + let mut b = gif(&[[0, 0, 0], [1, 1, 1]], None, None); + b[6..10].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]); + let n = b.len(); + b[n - 11..n - 7].copy_from_slice(&[1, 0, 1, 0]); + let info = scan(&b, 1).unwrap(); + assert_eq!((info.width, info.frames[0].width, info.frames[0].height), (65535, 1, 1)); + let screen = 65535u64 * 65535 * 4; + // 256 MiB of screen is free; the rest must be backed by file. + assert_eq!(info.bytes_to_back(4, 1 << 28), screen - (1 << 28)); + assert!(!plausible(info.bytes_to_back(4, 1 << 28), b.len(), 1 << 14)); + // A small screen needs only its first frame backed. + let small = scan(&gif(&[[0, 0, 0], [1, 1, 1]], None, None), 1).unwrap(); + assert_eq!(small.bytes_to_back(4, 1 << 28), 64); + assert!(plausible(64, 40, 1 << 14)); + // The ratio and the 1 MiB allowance, at and past the boundary. + assert!(plausible((1 << 20) + 10 * 4, 10, 4)); + assert!(!plausible((1 << 20) + 10 * 4 + 1, 10, 4)); +}