From 9435232e51abe7fa2616c7b4b5d70afda5287de9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 18:39:14 -0500 Subject: [PATCH] craft-tga: Targa reader and run-length writer (#892) FilmCraft's clean-room Targa reader and PhotoCraft's run-length writer in one crate. The reader returns RGBA8 and names the layout the file stored; the writer's packets end with their scan line. --- Cargo.lock | 7 + Cargo.toml | 1 + README.md | 4 + crates/tga/Cargo.toml | 14 ++ crates/tga/src/lib.rs | 374 ++++++++++++++++++++++++++++++++++++++++ crates/tga/src/tests.rs | 237 +++++++++++++++++++++++++ 6 files changed, 637 insertions(+) create mode 100644 crates/tga/Cargo.toml create mode 100644 crates/tga/src/lib.rs create mode 100644 crates/tga/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index bb1791b1a..013f57763 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1439,6 +1439,13 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "craft-tga" +version = "0.1.0" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "craft-tiff" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3fb055e06..a0c4d40b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,7 @@ craft-silhouette-studio = { path = "crates/silhouette-studio" } craft-svg = { path = "crates/svg" } craft-svg-paths = { path = "crates/svg-paths" } craft-text = { path = "crates/text" } +craft-tga = { path = "crates/tga" } craft-timeline = { path = "crates/timeline" } craft-tiff = { path = "crates/tiff" } craft-ui = { path = "crates/ui" } diff --git a/README.md b/README.md index 2d6bb651a..4167fbfce 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "` form on craft-xml elements, value codecs and the package writer; and InDesign Tagged Text, read and written. - [`craft-timeline`](crates/timeline/README.md): editorial timeline interchange: exact media time and SMPTE timecode, import/export reports, media path and `file://` URL handling, Avid Log Exchange (ALE) and CMX 3600 EDL documents, format detection. From FilmCraft. - [`craft-tiff`](crates/tiff/README.md): TIFF and BigTIFF IFD reader and writer, from LightCraft: diff --git a/crates/tga/Cargo.toml b/crates/tga/Cargo.toml new file mode 100644 index 000000000..0c52fef98 --- /dev/null +++ b/crates/tga/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "craft-tga" +description = "Clean-room Truevision Targa (TGA) reader and run-length writer: true-colour, grayscale and colour-mapped pictures, any origin, packets that end with their scan line." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +thiserror = { workspace = true } diff --git a/crates/tga/src/lib.rs b/crates/tga/src/lib.rs new file mode 100644 index 000000000..ac9d21497 --- /dev/null +++ b/crates/tga/src/lib.rs @@ -0,0 +1,374 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! Truevision Targa (`.tga`), read and written from the Truevision TGA File Format Specification 2.0. +//! +//! Reading ([`decode`]): image types 1, 2 and 3 (colour-mapped, true-colour, grayscale) and their +//! run-length encoded forms 9, 10 and 11; 15- and 16-bit (5-5-5, the top bit as alpha when the +//! descriptor says one attribute bit), 24-bit BGR and 32-bit BGRA true-colour, 8-bit grayscale and +//! 16-bit grayscale with alpha, colour maps of 15-, 16-, 24- or 32-bit entries indexed by 8 or 16 +//! bits; any origin corner. Run-length packets may cross scan lines (older writers do it). The +//! result is straight-alpha RGBA8, top row first, with [`Picture::layout`] naming what the file +//! stored. +//! +//! Writing ([`encode`]): 8-bit grayscale, grayscale with alpha, RGB or RGBA, run-length encoded +//! (types 10 and 11), rows stored bottom-up (the bottom-left origin every reader assumes, also +//! those that ignore the origin bits), alpha bits in the descriptor and the TGA 2.0 footer. A +//! packet never crosses a scan line: TGA 2.0 forbids it, and readers that decode a row at a time +//! misread files that do. +//! +//! Input is treated as hostile: the picture size is capped by [`Limits`], and a run-length file +//! must be long enough to hold the pixels its header declares before anything is allocated. + +#[cfg(test)] +mod tests; + +/// Length of the fixed header. +const HEADER: usize = 18; +/// TGA 2.0 files end with this signature (after two 32-bit area offsets). +const SIGNATURE: &[u8] = b"TRUEVISION-XFILE.\0"; +/// The TGA 2.0 footer written: no extension or developer area, then the signature. +const FOOTER: &[u8; 26] = b"\0\0\0\0\0\0\0\0TRUEVISION-XFILE.\0"; +/// Most pixels one packet holds (a 7-bit count, plus one). +const MAX_PACKET: usize = 128; + +/// Why a file could not be read or written. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// The header or the pixel data is damaged or cut short. + #[error("Targa: {0}")] + Malformed(&'static str), + /// A Targa image type this reader does not cover. + #[error("unsupported Targa: image type {0}")] + Unsupported(u8), + /// The file declares more than [`Limits`] allow. + #[error("Targa exceeds a limit: {0}")] + LimitExceeded(&'static str), + /// The picture handed to the writer cannot be stored. + #[error("Targa: cannot write: {0}")] + Encode(String), +} + +/// Result of this crate's functions. +pub type Result = std::result::Result; + +/// Caps on what a header may ask the reader to allocate, checked before anything is allocated. +/// `#[non_exhaustive]`: start from [`Limits::default`] and set the fields you change. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct Limits { + /// Most pixels (width × height) a picture may have. Default 2^28 (16384 × 16384). + pub max_pixels: u64, +} + +impl Default for Limits { + fn default() -> Self { + Self { max_pixels: 1 << 28 } + } +} + +/// What a picture's pixels are: what a file stored, or what the writer is given. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Layout { + /// One byte a pixel. + Gray, + /// Two bytes a pixel: gray, alpha. + GrayAlpha, + /// Three bytes a pixel. + Rgb, + /// Four bytes a pixel, straight alpha. + Rgba, +} + +impl Layout { + /// Bytes of one pixel. + pub fn channels(self) -> usize { + match self { + Layout::Gray => 1, + Layout::GrayAlpha => 2, + Layout::Rgb => 3, + Layout::Rgba => 4, + } + } +} + +/// A decoded picture. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Picture { + pub width: u32, + pub height: u32, + /// Straight-alpha RGBA8, top row first, whatever the file stored. + pub rgba: Vec, + /// What the file stored (a colour-mapped file reports its map entries' layout). + pub layout: Layout, + /// Whether any pixel is not fully opaque. + pub has_alpha: bool, +} + +/// Whether `bytes` looks like a Targa file: a plausible header and either a TGA 2.0 footer or, +/// for the original format (which has no signature), a Targa file extension on `name`. +pub fn sniff(name: &str, bytes: &[u8]) -> bool { + let header_ok = || { + let Some(h) = bytes.first_chunk::
() else { return false }; + let (map_type, kind, depth) = (h[1], h[2], h[16]); + let (w, ht) = (u16::from_le_bytes([h[12], h[13]]), u16::from_le_bytes([h[14], h[15]])); + map_type <= 1 && matches!(kind, 1 | 2 | 3 | 9 | 10 | 11) && w > 0 && ht > 0 && matches!(depth, 8 | 15 | 16 | 24 | 32) + }; + let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()); + let named = matches!(ext.as_deref(), Some("tga" | "targa" | "icb" | "vda" | "vst")); + header_ok() && (named || bytes.ends_with(SIGNATURE)) +} + +/// Pixel layout of the stored values (after the colour-map lookup, for mapped images). +#[derive(Clone, Copy)] +enum Stored { + /// 15/16-bit `ARRRRRGG GGGBBBBB`, little-endian; `alpha`: the top bit is an alpha bit. + Rgb555 { + alpha: bool, + }, + Bgr24, + Bgra32, + Gray8, + GrayAlpha16, +} + +impl Stored { + fn true_colour(depth: u8, attr_bits: u8) -> Option { + Some(match depth { + 15 => Stored::Rgb555 { alpha: false }, + 16 => Stored::Rgb555 { alpha: attr_bits == 1 }, + 24 => Stored::Bgr24, + 32 => Stored::Bgra32, + _ => return None, + }) + } + + fn bytes(self) -> usize { + match self { + Stored::Rgb555 { .. } | Stored::GrayAlpha16 => 2, + Stored::Bgr24 => 3, + Stored::Bgra32 => 4, + Stored::Gray8 => 1, + } + } + + fn layout(self) -> Layout { + match self { + Stored::Rgb555 { alpha: false } | Stored::Bgr24 => Layout::Rgb, + Stored::Rgb555 { alpha: true } | Stored::Bgra32 => Layout::Rgba, + Stored::Gray8 => Layout::Gray, + Stored::GrayAlpha16 => Layout::GrayAlpha, + } + } + + /// RGBA of one stored value (`p` holds exactly [`bytes`](Self::bytes) bytes). + fn rgba(self, p: &[u8]) -> [u8; 4] { + let b = |i: usize| p.get(i).copied().unwrap_or(0); + match self { + Stored::Rgb555 { alpha } => { + let v = u16::from_le_bytes([b(0), b(1)]); + let five = |s: u16| { + let c = ((v >> s) & 31) as u8; + (c << 3) | (c >> 2) + }; + let a = if !alpha || v & 0x8000 != 0 { 255 } else { 0 }; + [five(10), five(5), five(0), a] + } + Stored::Bgr24 => [b(2), b(1), b(0), 255], + Stored::Bgra32 => [b(2), b(1), b(0), b(3)], + Stored::Gray8 => [b(0), b(0), b(0), 255], + Stored::GrayAlpha16 => [b(0), b(0), b(0), b(1)], + } + } +} + +/// Decodes a Targa file to straight-alpha RGBA8, top row first. +pub fn decode(bytes: &[u8], limits: &Limits) -> Result { + let err = Error::Malformed; + let h = bytes.first_chunk::
().ok_or(err("truncated header"))?; + let (id_len, map_type, kind) = (usize::from(h[0]), h[1], h[2]); + let map_first = usize::from(u16::from_le_bytes([h[3], h[4]])); + let map_len = usize::from(u16::from_le_bytes([h[5], h[6]])); + let map_depth = h[7]; + let (w, ht) = (usize::from(u16::from_le_bytes([h[12], h[13]])), usize::from(u16::from_le_bytes([h[14], h[15]]))); + let (depth, descriptor) = (h[16], h[17]); + let attr_bits = descriptor & 0x0F; + let (right_to_left, top_to_bottom) = (descriptor & 0x10 != 0, descriptor & 0x20 != 0); + if w == 0 || ht == 0 { + return Err(err("empty picture")); + } + // Both are 16-bit, so the product fits. + let pixels = w * ht; + if pixels as u64 > limits.max_pixels { + return Err(Error::LimitExceeded("pixel count")); + } + let rle = kind >= 9; + // The stored value layout and, for colour-mapped images, the palette. + let mut palette: Vec<[u8; 4]> = Vec::new(); + let map_entry = if map_type == 1 { + Some(Stored::true_colour(map_depth, if map_depth == 16 { attr_bits.min(1) } else { 0 }).ok_or(err("colour map entry size"))?) + } else { + None + }; + let mut at = HEADER + id_len; + if let Some(l) = map_entry { + let end = at + map_len * l.bytes(); + let map = bytes.get(at..end).ok_or(err("truncated colour map"))?; + palette = map.chunks_exact(l.bytes()).map(|e| l.rgba(e)).collect(); + at = end; + } + let (stored_as, mapped) = match kind & 7 { + 1 => { + if map_type != 1 || !matches!(depth, 8 | 16) { + return Err(err("colour-mapped picture without an 8/16-bit index and a colour map")); + } + (if depth == 8 { Stored::Gray8 } else { Stored::GrayAlpha16 }, true) + } + 2 => (Stored::true_colour(depth, attr_bits).ok_or(err("true-colour pixel depth"))?, false), + 3 => match depth { + 8 => (Stored::Gray8, false), + 16 => (Stored::GrayAlpha16, false), + _ => return Err(err("grayscale pixel depth")), + }, + _ => return Err(Error::Unsupported(kind)), + }; + let data = bytes.get(at..).unwrap_or_default(); + let bpp = stored_as.bytes(); + // The stored values, in file order. + let stored: Vec = if rle { + unpack_rle(data, pixels, bpp)? + } else { + let n = pixels.checked_mul(bpp).ok_or(Error::LimitExceeded("image size"))?; + data.get(..n).ok_or(err("truncated pixel data"))?.to_vec() + }; + let mut rgba = vec![0u8; pixels.checked_mul(4).ok_or(Error::LimitExceeded("image size"))?]; + for (i, v) in stored.chunks_exact(bpp).enumerate().take(pixels) { + let (x, y) = (i % w, i / w); + let x = if right_to_left { w - 1 - x } else { x }; + let y = if top_to_bottom { y } else { ht - 1 - y }; + let px = if mapped { + let byte = |i: usize| v.get(i).copied().unwrap_or(0); + let index = if bpp == 1 { usize::from(byte(0)) } else { usize::from(u16::from_le_bytes([byte(0), byte(1)])) }; + *index.checked_sub(map_first).and_then(|k| palette.get(k)).ok_or(err("colour index outside the colour map"))? + } else { + stored_as.rgba(v) + }; + let o = (y * w + x) * 4; + if let Some(d) = rgba.get_mut(o..o + 4) { + d.copy_from_slice(&px); + } + } + // 32-bit files from writers that leave the attribute bits unset often store zero alpha + // everywhere: such a picture is opaque, not invisible. + let alpha_used = match (stored_as, mapped) { + (Stored::Bgra32, false) => attr_bits != 0 || rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 0), + _ => true, + }; + if !alpha_used { + rgba.as_chunks_mut::<4>().0.iter_mut().for_each(|p| p[3] = 255); + } + let has_alpha = rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255); + let layout = match (mapped, map_entry) { + (true, Some(entry)) => entry.layout(), + _ => stored_as.layout(), + }; + Ok(Picture { width: w as u32, height: ht as u32, rgba, layout, has_alpha }) +} + +/// Expands run-length packets into `pixels` stored values of `bpp` bytes each. Packets may span +/// scan lines (the TGA 2.0 spec discourages it; older writers do it). +fn unpack_rle(mut data: &[u8], pixels: usize, bpp: usize) -> Result> { + let short = Error::Malformed("truncated run-length data"); + // A two-byte packet expands to at most 128 values: a header asking for more is lying. + let bound = data.len().saturating_mul(64).saturating_add(128); + if pixels > bound { + return Err(short); + } + let total = pixels.checked_mul(bpp).ok_or(Error::LimitExceeded("image size"))?; + let mut out = Vec::with_capacity(total); + while out.len() < total { + let (&head, rest) = data.split_first().ok_or(short.clone())?; + let count = usize::from(head & 0x7F) + 1; + let want = (total - out.len()).min(count * bpp); + if head & 0x80 != 0 { + let v = rest.get(..bpp).ok_or(short.clone())?; + for _ in 0..want / bpp { + out.extend_from_slice(v); + } + data = rest.get(bpp..).unwrap_or_default(); + } else { + let n = count * bpp; + let v = rest.get(..n).ok_or(short.clone())?; + out.extend_from_slice(v.get(..want).unwrap_or(v)); + data = rest.get(n..).unwrap_or_default(); + } + } + Ok(out) +} + +/// Encodes 8-bit pixels (`height` rows of `width × layout.channels()` bytes, top row first) as a +/// run-length encoded Targa. +pub fn encode(width: u32, height: u32, layout: Layout, data: &[u8]) -> Result> { + let (bpp, image_type, alpha_bits) = match layout { + Layout::Gray => (1usize, 11u8, 0u8), + Layout::GrayAlpha => (2, 11, 8), + Layout::Rgb => (3, 10, 0), + Layout::Rgba => (4, 10, 8), + }; + let (Ok(w16), Ok(h16)) = (u16::try_from(width), u16::try_from(height)) else { + return Err(Error::Encode(format!("dimensions exceed 65535x65535: {width}x{height}"))); + }; + let row = usize::from(w16) * bpp; + if data.len() != row * usize::from(h16) { + return Err(Error::Encode("buffer size mismatch".into())); + } + let mut out = Vec::with_capacity(HEADER + data.len() + data.len() / MAX_PACKET + FOOTER.len()); + out.extend_from_slice(&[0, 0, image_type, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + out.extend_from_slice(&w16.to_le_bytes()); + out.extend_from_slice(&h16.to_le_bytes()); + // Bits per pixel; the descriptor holds the alpha bits, origin bits 0 (bottom-left). + out.extend_from_slice(&[(bpp * 8) as u8, alpha_bits]); + let mut px = vec![0u8; row]; + // `row` is 0 only for an empty picture, which has no rows to write. + for line in data.chunks_exact(row.max(1)).rev() { + px.copy_from_slice(line); + // Targa stores colour as BGR. + if bpp >= 3 { + for p in px.chunks_exact_mut(bpp) { + p.swap(0, 2); + } + } + rle_row(&px, bpp, &mut out); + } + out.extend_from_slice(FOOTER); + Ok(out) +} + +/// One scan line as run-length packets that end with it: a run of two or more equal pixels is a +/// run packet, anything else goes out in raw packets. +fn rle_row(row: &[u8], bpp: usize, out: &mut Vec) { + let px: Vec<&[u8]> = row.chunks_exact(bpp).collect(); + let n = px.len(); + let at = |i: usize| px.get(i).copied().unwrap_or_default(); + let mut i = 0; + while i < n { + let mut run = 1; + while i + run < n && run < MAX_PACKET && at(i + run) == at(i) { + run += 1; + } + if run >= 2 { + out.push(0x80 | (run - 1) as u8); + out.extend_from_slice(at(i)); + i += run; + continue; + } + // Raw until two equal pixels start a run, the packet is full or the row ends. The first + // pixel always goes in: it differs from the one after it, or it is the row's last. + let start = i; + while i < n && i - start < MAX_PACKET && !(i > start && i + 1 < n && at(i) == at(i + 1)) { + i += 1; + } + out.push((i - start - 1) as u8); + for p in px.get(start..i).unwrap_or_default() { + out.extend_from_slice(p); + } + } +} diff --git a/crates/tga/src/tests.rs b/crates/tga/src/tests.rs new file mode 100644 index 000000000..06109a179 --- /dev/null +++ b/crates/tga/src/tests.rs @@ -0,0 +1,237 @@ +//! The reader's tests come from FilmCraft's decoder, the writer's from PhotoCraft's encoder. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +fn header(kind: u8, w: u16, h: u16, depth: u8, descriptor: u8) -> Vec { + let mut b = vec![0, 0, kind, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + b.extend_from_slice(&w.to_le_bytes()); + b.extend_from_slice(&h.to_le_bytes()); + b.extend_from_slice(&[depth, descriptor]); + b +} + +fn dec(bytes: &[u8]) -> Result { + decode(bytes, &Limits::default()) +} + +#[test] +fn bottom_up_bgr_is_flipped() { + let mut f = header(2, 2, 2, 24, 0); + // Bottom row first: blue, green; then the top row: red, white. + f.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]); + let r = dec(&f).unwrap(); + assert_eq!((r.width, r.height, r.has_alpha, r.layout), (2, 2, false, Layout::Rgb)); + assert_eq!(r.rgba, [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255, 0, 255, 0, 255]); +} + +#[test] +fn every_origin_corner() { + // 2 × 2 gray, stored values 1 2 / 3 4 in file order. + for (descriptor, want) in [(0x00, [3, 4, 1, 2]), (0x10, [4, 3, 2, 1]), (0x20, [1, 2, 3, 4]), (0x30, [2, 1, 4, 3])] { + let mut f = header(3, 2, 2, 8, descriptor); + f.extend_from_slice(&[1, 2, 3, 4]); + let r = dec(&f).unwrap(); + assert_eq!(r.rgba.chunks(4).map(|p| p[0]).collect::>(), want, "descriptor {descriptor:#x}"); + assert_eq!(r.layout, Layout::Gray); + } +} + +#[test] +fn rle_runs_and_raw_packets_cross_lines() { + let mut f = header(10, 3, 2, 32, 0x28); + // A run of 4 translucent red, then 2 raw pixels. + f.extend_from_slice(&[0x83, 0, 0, 255, 128, 0x01, 1, 2, 3, 255, 4, 5, 6, 255]); + let r = dec(&f).unwrap(); + assert!(r.has_alpha); + assert_eq!(r.layout, Layout::Rgba); + assert_eq!(&r.rgba[..16], &[255, 0, 0, 128].repeat(4)[..]); + assert_eq!(&r.rgba[16..], &[3, 2, 1, 255, 6, 5, 4, 255]); +} + +#[test] +fn colour_map_and_16_bit_alpha() { + let mut f = header(1, 2, 1, 8, 0x20); + f[1] = 1; // colour map present + f[3..5].copy_from_slice(&1u16.to_le_bytes()); // first entry index 1 + f[5..7].copy_from_slice(&2u16.to_le_bytes()); + f[7] = 24; + f.extend_from_slice(&[10, 20, 30, 40, 50, 60, 2, 1]); + let r = dec(&f).unwrap(); + assert_eq!(r.rgba, [60, 50, 40, 255, 30, 20, 10, 255]); + assert_eq!(r.layout, Layout::Rgb); + f[18 + 6] = 0; // index 0 is below the map's first entry + assert_eq!(dec(&f), Err(Error::Malformed("colour index outside the colour map"))); + + let mut f = header(2, 2, 1, 16, 0x21); + f.extend_from_slice(&0xFC00u16.to_le_bytes()); // opaque full red + f.extend_from_slice(&0x03E0u16.to_le_bytes()); // transparent full green + let r = dec(&f).unwrap(); + assert_eq!(r.rgba, [255, 0, 0, 255, 0, 255, 0, 0]); + + // 16-bit grayscale with alpha. + let mut f = header(3, 1, 1, 16, 0x28); + f.extend_from_slice(&[7, 9]); + let r = dec(&f).unwrap(); + assert_eq!((r.rgba.as_slice(), r.layout, r.has_alpha), (&[7, 7, 7, 9][..], Layout::GrayAlpha, true)); +} + +#[test] +fn zero_alpha_32_bit_without_attribute_bits_is_opaque() { + let mut f = header(2, 1, 1, 32, 0); + f.extend_from_slice(&[1, 2, 3, 0]); + let r = dec(&f).unwrap(); + assert_eq!((r.rgba.as_slice(), r.has_alpha), (&[3, 2, 1, 255][..], false)); +} + +#[test] +fn hostile_headers_fail_without_allocating() { + // 65535 × 65535 run-length encoded from a few bytes, truncated raw data, bad types. + let mut f = header(10, 65535, 65535, 32, 0); + f.extend_from_slice(&[0xFF, 1, 2, 3, 4]); + assert_eq!(dec(&f), Err(Error::LimitExceeded("pixel count"))); + let mut f = header(10, 16384, 16384, 32, 0); + f.extend_from_slice(&[0xFF, 1, 2, 3, 4]); + assert_eq!(dec(&f), Err(Error::Malformed("truncated run-length data"))); + let mut f = header(2, 4, 4, 24, 0); + f.extend_from_slice(&[0; 10]); + assert_eq!(dec(&f), Err(Error::Malformed("truncated pixel data"))); + assert!(dec(&header(2, 1, 1, 7, 0)).is_err()); + assert!(dec(&header(1, 1, 1, 8, 0)).is_err()); + assert_eq!(dec(&header(2, 0, 1, 24, 0)), Err(Error::Malformed("empty picture"))); + assert_eq!(dec(&header(4, 1, 1, 24, 0)), Err(Error::Unsupported(4))); + assert_eq!(dec(&[0; 5]), Err(Error::Malformed("truncated header"))); + for n in 0..40 { + let mut f = header(9, 3, 3, 8, 0); + f[1] = 1; + f[5] = 2; + f[7] = 16; + f.extend_from_slice(&[0x82, 1, 0, 1, 0x01, 0, 0]); + f.truncate(n); + let _ = dec(&f); + } +} + +#[test] +fn the_pixel_limit_is_checked() { + let mut f = header(3, 2, 2, 8, 0); + f.extend_from_slice(&[0; 4]); + assert!(decode(&f, &Limits { max_pixels: 4 }).is_ok()); + assert_eq!(decode(&f, &Limits { max_pixels: 3 }), Err(Error::LimitExceeded("pixel count"))); +} + +#[test] +fn sniffs_by_footer_or_name() { + let mut f = header(2, 1, 1, 24, 0); + f.extend_from_slice(&[0, 0, 0]); + assert!(sniff("x.TGA", &f)); + assert!(!sniff("x.bin", &f)); + f.extend_from_slice(&[0; 8]); + f.extend_from_slice(SIGNATURE); + assert!(sniff("x.bin", &f)); + assert!(!sniff("x.tga", b"\x89PNG\r\n\x1a\n0000000000")); +} + +#[test] +fn run_packets_at_the_128_pixel_boundary() { + for bpp in 1..=4 { + for (pixels, headers) in [(1, vec![0]), (2, vec![0x81]), (127, vec![0xfe]), (128, vec![0xff]), (129, vec![0xff, 0])] { + let pixel: Vec = (1..=bpp as u8).collect(); + let mut encoded = Vec::new(); + rle_row(&pixel.repeat(pixels), bpp, &mut encoded); + let expected: Vec = headers.into_iter().flat_map(|header| std::iter::once(header).chain(pixel.iter().copied())).collect(); + assert_eq!(encoded, expected, "{pixels} pixels, {bpp} bytes per pixel"); + } + } +} + +#[test] +fn raw_packets_at_the_128_pixel_boundary() { + for bpp in 1..=4 { + for pixels in [127usize, 128, 129] { + let row: Vec = (0..pixels).flat_map(|x| vec![x as u8; bpp]).collect(); + let mut encoded = Vec::new(); + rle_row(&row, bpp, &mut encoded); + let mut expected = vec![(pixels.min(128) - 1) as u8]; + expected.extend_from_slice(&row[..pixels.min(128) * bpp]); + if pixels > 128 { + expected.push(0); + expected.extend_from_slice(&row[128 * bpp..]); + } + assert_eq!(encoded, expected, "{pixels} pixels, {bpp} bytes per pixel"); + } + } +} + +#[test] +fn raw_packets_stop_before_a_run_of_complete_pixels() { + // Equal first channels alone do not make an RGBA run; alpha participates too. + let a = [10, 20, 30, 40]; + let b = [10, 20, 30, 41]; + let c = [50, 60, 70, 80]; + let row = [a, b, c, c, a].concat(); + let mut encoded = Vec::new(); + rle_row(&row, 4, &mut encoded); + assert_eq!(encoded, [vec![1], a.to_vec(), b.to_vec(), vec![0x81], c.to_vec(), vec![0], a.to_vec()].concat()); +} + +#[test] +fn dimensions_fit_the_unsigned_16_bit_header() { + for (width, height) in [(65535u32, 1u32), (1, 65535)] { + let file = encode(width, height, Layout::Gray, &vec![0; 65535]).unwrap(); + assert_eq!(&file[12..14], &(width as u16).to_le_bytes()); + assert_eq!(&file[14..16], &(height as u16).to_le_bytes()); + } + for (width, height) in [(65536u32, 1u32), (1, 65536)] { + assert!(matches!(encode(width, height, Layout::Gray, &vec![0; 65536]), Err(Error::Encode(_))), "{width}x{height}"); + } +} + +#[test] +fn wrong_buffer_sizes_are_refused() { + for layout in [Layout::Gray, Layout::GrayAlpha, Layout::Rgb, Layout::Rgba] { + let len = 2 * 2 * layout.channels(); + for bytes in [len - 1, len + 1] { + assert!(matches!(encode(2, 2, layout, &vec![0; bytes]), Err(Error::Encode(_))), "{layout:?}: {bytes} bytes"); + } + } + assert!(encode(0, 0, Layout::Rgb, &[]).is_ok()); +} + +/// What the writer produces, the reader returns: every layout, with rows that hold runs, raw +/// stretches and packets longer than 128 pixels, and no packet crossing a row. +#[test] +fn written_files_read_back() { + for layout in [Layout::Gray, Layout::GrayAlpha, Layout::Rgb, Layout::Rgba] { + let n = layout.channels(); + let (w, h) = (300usize, 3usize); + let data: Vec = + (0..w * h).flat_map(|i| (0..n).map(move |c| if i % w < 140 { 9 + c as u8 } else { (i * 7 + c * 31) as u8 | 1 })).collect(); + let file = encode(w as u32, h as u32, layout, &data).unwrap(); + assert!(file.ends_with(SIGNATURE) && sniff("x.bin", &file)); + // Walk the packets: each row's packets add up to exactly the row. + let mut at = HEADER; + for _ in 0..h { + let mut px = 0; + while px < w { + let count = usize::from(file[at] & 0x7F) + 1; + at += 1 + if file[at] & 0x80 != 0 { n } else { count * n }; + px += count; + } + assert_eq!(px, w, "a packet crossed a row"); + } + assert_eq!(at, file.len() - FOOTER.len()); + let r = dec(&file).unwrap(); + assert_eq!((r.width, r.height, r.layout), (w as u32, h as u32, layout)); + let want: Vec = data + .chunks(n) + .flat_map(|p| match layout { + Layout::Gray => [p[0], p[0], p[0], 255], + Layout::GrayAlpha => [p[0], p[0], p[0], p[1]], + Layout::Rgb => [p[0], p[1], p[2], 255], + Layout::Rgba => [p[0], p[1], p[2], p[3]], + }) + .collect(); + assert_eq!(r.rgba, want, "{layout:?}"); + } +}