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.
This commit is contained in:
Dylan M. Taylor
2026-11-11 18:53:16 -05:00
committed by GitHub
parent 4f096b64d6
commit 77591e5efb
6 changed files with 400 additions and 0 deletions
Generated
+7
View File
@@ -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"
+1
View File
@@ -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" }
+4
View File
@@ -114,6 +114,10 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
millimetres to `G0`/`G1` moves with laser power and feed, for cutting lasers and plotters, as a
commented file or as bare lines for streaming. Its `grbl` module has the real-time command bytes
and the status and acknowledgement parsers.
- [`craft-gif`](crates/gif/src/lib.rs): GIF block reader, from PhotoCraft: the logical screen,
every frame's position, delay, disposal and transparent colour, the loop count and the first
frame's colour table, without decompressing pixels. Tells an animation from a still and whether
a file can hold the picture it declares before a decoder runs.
- [`craft-hdr`](crates/hdr/src/lib.rs): Radiance HDR (RGBE) picture reader, from FilmCraft: flat,
old and new run-length scan lines and all eight axis orders, to linear float RGB. Scan lines are
validated before the picture is allocated.
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "craft-gif"
description = "GIF block reader: the logical screen, each frame's position, delay and transparency, the loop count and colour tables, without decompressing pixels."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dev-dependencies]
# Writes the animated test files the scan is checked against; not a product dependency.
image = { version = "0.25", default-features = false, features = ["gif"] }
+209
View File
@@ -0,0 +1,209 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! The blocks of a GIF, read without decompressing any pixels (GIF89a specification: Logical
//! Screen Descriptor, Global and Local Color Tables, Graphic Control Extension, Application
//! Extension, Image Descriptor).
//!
//! [`scan`] walks the file once and reports what an importer needs before it decodes anything:
//! the logical screen size, every frame's position, size, delay and transparent colour, whether
//! the file reached its trailer, and the loop count. That answers "is this an animation, how
//! long is it, how many frames" and "can a file this small hold a picture this large" from the
//! headers alone. [`palette`] gives the colour table of the first frame, for opening a GIF as an
//! indexed image.
//!
//! Decoding pixels is not part of this crate; the apps use a GIF decoder for that and check its
//! work against what [`scan`] says first.
//!
//! Input is treated as hostile: every read is bounds-checked, and the number of frames recorded
//! is capped by the caller.
#[cfg(test)]
mod tests;
/// One frame (image descriptor) of a GIF.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Frame {
/// File offset of the image descriptor (its `0x2C` separator).
pub offset: usize,
/// Position of the frame on the logical screen.
pub left: u16,
pub top: u16,
pub width: u16,
pub height: u16,
/// Delay before the next frame in hundredths of a second, as stored (0 when the frame has no
/// Graphic Control Extension). Players show very small delays longer; that is the caller's rule.
pub delay_cs: u16,
/// The transparent colour index, when the frame's Graphic Control Extension names one.
pub transparent: Option<u8>,
/// 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<Frame>,
/// 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<u16>,
}
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<bool> {
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<u16> {
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<usize> {
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<Info> {
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<bool> {
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<u8>)> {
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,
}
}
}
+164
View File
@@ -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<u8>, local: Option<&[[u8; 3]]>) -> Vec<u8> {
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<u8>, 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<image::codecs::gif::Repeat>) -> Vec<u8> {
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::<Vec<_>>(), [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));
}