mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
The image crate's Targa encoder let run-length packets run 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 misread such files: Pillow fails on them with "buffer overrun", and texture tools and game engines reject them. Targa is now written by our own encoder (codecs::tga), as Photoshop writes it: RLE true colour or grayscale, every packet within its row, rows stored bottom-up (the origin readers that ignore the descriptor assume), the alpha bits in the descriptor and the TGA 2.0 footer. Reading stays with the image crate.
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Vec<u8>, 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<u8>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user