diff --git a/crates/codecs/README.md b/crates/codecs/README.md index d4f7094894..46baf50f82 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -38,7 +38,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | WebP | yes | yes | U8 | RGB, RGBA | yes | yes | yes | yes | no | no | yes (VP8, when `webp_lossless` is off) | `image-webp` / built-in VP8 | | GIF | yes | yes | U8 | RGBA | 1-bit | no | no | no | no | no | yes (256-colour palette) | `image` | | BMP | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` | -| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` | +| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` (read) / built in (RLE write, one scanline per packet) | | ICO | yes | yes | U8 | RGBA (at most 256×256) | yes | no | no | no | no | no | no | `image` | | DDS | yes | **no** | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | n/a | built in | | Netpbm (PBM/PGM/PPM/PAM/PFM) | yes | yes | U8, U16, F32 (float: PFM gray/RGB only) | all six (PAM `CMYK`/`CMYK_ALPHA`) | yes (PAM) | no | no | no | no | no | no | built in | diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index afb7f96791..32e68b336a 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -10,6 +10,7 @@ pub(crate) mod jpeg; pub(crate) mod pcx; pub(crate) mod png; pub(crate) mod pnm; +pub(crate) mod tga; pub(crate) mod tiff; pub(crate) mod tiff_exif; pub(crate) mod tiff_ifd; diff --git a/crates/codecs/src/codecs/tga.rs b/crates/codecs/src/codecs/tga.rs new file mode 100644 index 0000000000..7b52533d7b --- /dev/null +++ b/crates/codecs/src/codecs/tga.rs @@ -0,0 +1,90 @@ +//! Targa writer. The `image` crate still reads Targa ([`super::via_image`]); writing is ours +//! because its run-length packets ran on from the end of one row into the next. TGA 2.0 says a +//! packet must not cross a scanline, and readers that decode a row at a time (Pillow, texture +//! converters, game engines) misread such files (#6382). +//! +//! Like Photoshop's Targa: RLE (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. + +use crate::Format; +use crate::error::CodecError; +use crate::image::{ChannelLayout, Image, SampleType}; + +/// Most pixels one packet holds (a 7-bit count, plus one). +const MAX_PACKET: usize = 128; + +/// The TGA 2.0 footer: no extension or developer area, then the signature. +const FOOTER: &[u8; 26] = b"\0\0\0\0\0\0\0\0TRUEVISION-XFILE.\0"; + +/// Encodes an 8-bit Gray, Gray+alpha, RGB or RGBA image as a run-length encoded Targa. +pub(crate) fn encode(img: &Image) -> Result, CodecError> { + let (w, h) = img.dimensions(); + if img.sample_type() != SampleType::U8 { + return Err(CodecError::encode(Format::Tga, format!("{:?} samples (Targa stores 8 bits)", img.sample_type()))); + } + let (bpp, image_type, alpha_bits) = match img.layout() { + ChannelLayout::Gray => (1, 11, 0), + ChannelLayout::GrayA => (2, 11, 8), + ChannelLayout::Rgb => (3, 10, 0), + ChannelLayout::Rgba => (4, 10, 8), + l => return Err(CodecError::encode(Format::Tga, format!("no Targa layout for {l:?}"))), + }; + let (Ok(w16), Ok(h16)) = (u16::try_from(w), u16::try_from(h)) else { + return Err(CodecError::encode(Format::Tga, format!("dimensions exceed 65535x65535: {w}x{h}"))); + }; + let row = w as usize * bpp; + let data = img.data(); + if data.len() != row * h as usize { + return Err(CodecError::InvalidImage("buffer size mismatch".into())); + } + let mut out = Vec::with_capacity(18 + 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]; + for y in (0..h as usize).rev() { + px.copy_from_slice(&data[y * row..(y + 1) * row]); + // 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 scanline 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 mut i = 0; + while i < n { + let mut run = 1; + while i + run < n && run < MAX_PACKET && px[i + run] == px[i] { + run += 1; + } + if run >= 2 { + out.push(0x80 | (run - 1) as u8); + out.extend_from_slice(px[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 && px[i] == px[i + 1]) { + i += 1; + } + out.push((i - start - 1) as u8); + for p in &px[start..i] { + out.extend_from_slice(p); + } + } +} diff --git a/crates/codecs/src/codecs/via_image.rs b/crates/codecs/src/codecs/via_image.rs index 7e7eb76b2f..fd45f6a173 100644 --- a/crates/codecs/src/codecs/via_image.rs +++ b/crates/codecs/src/codecs/via_image.rs @@ -1,5 +1,6 @@ //! Formats delegated to the `image` crate (pure-Rust features only): -//! GIF, BMP, TGA, ICO, QOI, Radiance HDR, and AVIF encode (feature `avif`). +//! GIF, BMP, TGA, ICO, QOI, Radiance HDR, and AVIF encode (feature `avif`). Targa is written by +//! [`super::tga`] and only read here. use std::io::Cursor; @@ -163,6 +164,10 @@ pub(crate) fn encode(f: Format, src: &Image, plan: Plan, opts: &EncodeOptions) - return Err(CodecError::encode(f, format!("dimensions exceed {mw}x{mh}"))); } let img = src.converted(plan.layout, plan.sample); + // Targa is written by our encoder: the image crate's RLE packets cross scanlines (#6382). + if f == Format::Tga { + return super::tga::encode(&img); + } let dynimg = to_dynamic(&img)?; let mut cursor = Cursor::new(Vec::new()); match f { diff --git a/crates/codecs/tests/tga.rs b/crates/codecs/tests/tga.rs new file mode 100644 index 0000000000..279aa47f8b --- /dev/null +++ b/crates/codecs/tests/tga.rs @@ -0,0 +1,91 @@ +//! Targa writing (#6382): run-length packets end with their scanline, rows are stored bottom-up, +//! and the file reads back exactly. + +use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, decode_as, encode}; + +/// The packets of a run-length encoded Targa body, as (pixels, is a run), from just after the +/// 18-byte header (no image ID or colour map) up to `pixels` pixels. +fn packets(file: &[u8], bpp: usize, pixels: usize) -> Vec<(usize, bool)> { + let mut at = 18; + let mut seen = 0; + let mut out = Vec::new(); + while seen < pixels { + let head = file[at]; + let count = usize::from(head & 0x7f) + 1; + let run = head & 0x80 != 0; + at += 1 + if run { bpp } else { count * bpp }; + seen += count; + out.push((count, run)); + } + out +} + +/// A `w`×`h` image whose rows mix long runs (wider than a packet), short ones and noise, and +/// whose last pixel equals the next row's first, so a packet would run on if it could. +fn sample(w: u32, h: u32, layout: ChannelLayout) -> Image { + let n = layout.channels(); + let mut data = Vec::new(); + for y in 0..h { + for x in 0..w { + let v = match x { + _ if x < 150 => 40, + _ if x % 3 == 0 => (x * 7 + y * 13) as u8, + _ => 200, + }; + let px = [v, v.wrapping_add(y as u8), v ^ 0x5a, 255 - v]; + data.extend_from_slice(&px[..n]); + } + } + Image::from_u8(w, h, layout, data).unwrap() +} + +#[test] +fn rle_packets_end_with_their_scanline() { + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { + let (w, h) = (300u32, 4u32); + let file = encode(&sample(w, h, layout), Format::Tga, &EncodeOptions::default()).unwrap(); + let bpp = layout.channels(); + let mut col = 0; + for (count, _) in packets(&file, bpp, (w * h) as usize) { + assert!(count <= 128, "{layout:?}: a packet holds at most 128 pixels"); + assert!(col + count <= w as usize, "{layout:?}: a packet runs from column {col} past the row's end"); + col = (col + count) % w as usize; + } + // A solid image is one run per row (three packets of 128, 128 and 44), not one long run. + let solid = Image::from_u8(w, h, layout, vec![90; (w * h) as usize * bpp]).unwrap(); + let file = encode(&solid, Format::Tga, &EncodeOptions::default()).unwrap(); + let p = packets(&file, bpp, (w * h) as usize); + assert_eq!(p.len(), 3 * h as usize, "{layout:?}"); + assert!(p.iter().all(|&(_, run)| run)); + } +} + +#[test] +fn the_file_is_bottom_up_rle_with_its_alpha_bits_and_the_tga2_footer() { + let mut data = vec![0u8; 2 * 2 * 4]; + // Top row red, bottom row blue, opaque. + for (i, px) in data.as_chunks_mut::<4>().0.iter_mut().enumerate() { + *px = if i < 2 { [255, 0, 0, 255] } else { [0, 0, 255, 255] }; + } + let file = encode(&Image::from_u8(2, 2, ChannelLayout::Rgba, data).unwrap(), Format::Tga, &EncodeOptions::default()).unwrap(); + assert_eq!(file[2], 10, "run-length true colour"); + assert_eq!((file[16], file[17]), (32, 8), "32 bits per pixel, 8 alpha bits, bottom-left origin"); + // The first stored row is the bottom one: a run of two blue pixels, in BGRA. + assert_eq!(&file[18..23], &[0x81, 255, 0, 0, 255]); + assert!(file.ends_with(b"TRUEVISION-XFILE.\0")); + let gray = encode(&Image::from_u8(2, 1, ChannelLayout::Gray, vec![1, 2]).unwrap(), Format::Tga, &EncodeOptions::default()).unwrap(); + assert_eq!((gray[2], gray[16], gray[17]), (11, 8, 0), "run-length grayscale, no alpha bits"); +} + +#[test] +fn every_layout_reads_back_exactly() { + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { + for (w, h) in [(1, 1), (300, 4), (129, 3), (5, 130)] { + let img = sample(w, h, layout); + let file = encode(&img, Format::Tga, &EncodeOptions::default()).unwrap(); + let back = decode_as(Format::Tga, &file).unwrap(); + assert_eq!((back.dimensions(), back.layout()), ((w, h), layout), "{layout:?} {w}x{h}"); + assert_eq!(back.data(), img.data(), "{layout:?} {w}x{h}"); + } + } +}