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.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 18:39:14 -05:00
1 parent b2f247d3bf
commit 9435232e51
6 files changed
+637

No files matched your search

Generated
+7
View File
@@ -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"
+1
View File
@@ -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" }
+4
View File
@@ -120,6 +120,10 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
Hasselblad 3FR/FFF, Minolta MRW, Epson ERF, Mamiya MEF, Kodak DCS, Pentax PEF, Samsung SRW and Canon CRW (through
`craft-crw`) so far; demosaicing and everything after stay with the develop
pipeline.
- [`craft-tga`](crates/tga/src/lib.rs): Truevision Targa, from FilmCraft (reader) and PhotoCraft
(writer): true-colour, grayscale and colour-mapped pictures, raw or run-length encoded, any
origin, to RGBA8, and a run-length writer whose packets end with their scan line. Bounded on
hostile input.
- [`craft-idml`](crates/idml/README.md): IDML (InDesign Markup Language) packages: the ZIP container read with bounded part sizes and lenient part names, `designmap.xml` with its includes in place, the `<Properties>` 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:
+14
View File
@@ -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 }
+374
View File
@@ -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<T> = std::result::Result<T, Error>;
/// 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<u8>,
/// 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::<HEADER>() 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<Self> {
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<Picture> {
let err = Error::Malformed;
let h = bytes.first_chunk::<HEADER>().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<u8> = 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<Vec<u8>> {
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<Vec<u8>> {
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<u8>) {
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);
}
}
}
+237
View File
@@ -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<u8> {
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<Picture> {
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::<Vec<_>>(), 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<u8> = (1..=bpp as u8).collect();
let mut encoded = Vec::new();
rle_row(&pixel.repeat(pixels), bpp, &mut encoded);
let expected: Vec<u8> = 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<u8> = (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<u8> =
(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<u8> = 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:?}");
}
}