M4.69: Targa export at 16, 24 or 32 bits per pixel

document.export {format: "tga"} writes uncompressed true-colour Targa files
through the raster export path (resolution, background, anti-aliasing,
artboard ranges): depth 16 (5-5-5 with a one-bit alpha), 24 (flattened on
white) or 32 (8-bit alpha), rows bottom-up, with the version 2 footer.
Export As offers it with the tgaOptions dialog (Depth).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 15:24:51 +07:00
co-authored by Claude Opus 5.5
parent f2fc8782c7
commit b578c32f4d
16 changed files with 224 additions and 26 deletions
+8
View File
@@ -9,6 +9,7 @@ pub mod gif;
pub mod jpeg;
pub mod png;
pub mod quantize;
pub mod tga;
pub mod tiff;
use vectorcraft_doc::{Document, Node, NodeKind};
@@ -27,6 +28,7 @@ pub enum RasterFormat {
Gif,
Tiff,
Bmp,
Tga,
}
/// How a raster export renders and encodes.
@@ -49,6 +51,8 @@ pub struct RasterExportOptions {
pub tiff: tiff::TiffOptions,
/// BMP layout, depth, compression and row order.
pub bmp: bmp::BmpOptions,
/// Targa depth.
pub tga: tga::TgaOptions,
}
impl Default for RasterExportOptions {
@@ -63,6 +67,7 @@ impl Default for RasterExportOptions {
palette: quantize::PaletteOptions::default(),
tiff: tiff::TiffOptions::default(),
bmp: bmp::BmpOptions::default(),
tga: tga::TgaOptions::default(),
}
}
}
@@ -109,6 +114,7 @@ impl RasterExportOptions {
}
RasterFormat::Tiff => tiff::encode(img, self.ppi, &self.tiff),
RasterFormat::Bmp => bmp::encode(&img.to_straight(), img.width, img.height, self.ppi, &self.bmp, &self.palette),
RasterFormat::Tga => tga::encode(&img.to_straight(), img.width, img.height, &self.tga),
}
}
@@ -191,4 +197,6 @@ mod tests_jpeg;
#[cfg(test)]
mod tests_palette;
#[cfg(test)]
mod tests_tga;
#[cfg(test)]
mod tests_tiff;
+30
View File
@@ -0,0 +1,30 @@
//! Targa export: depths, alpha and the footer.
use super::on_white;
use super::tests_bmp::{art, decode, u16_at};
use super::tga::{self, TgaOptions};
#[test]
fn targa_depths_alpha_and_footer() {
let (w, h) = (7, 3);
let px = art(w, h);
for (depth, alpha_bits) in [(16, 1), (24, 0), (32, 8)] {
let file = tga::encode(&px, w, h, &TgaOptions { depth }).unwrap();
assert_eq!((file[2], file[16], file[17]), (2, depth, alpha_bits), "true colour, the depth byte, alpha bits");
assert_eq!((u16_at(&file, 12), u16_at(&file, 14)), (7, 3));
assert_eq!(file.len(), 18 + (w * h) as usize * usize::from(depth / 8) + 26);
assert!(file.ends_with(b"TRUEVISION-XFILE.\0"));
let img = decode(&file, image::ImageFormat::Tga);
assert_eq!(img.dimensions(), (w, h));
let want = on_white(px[(2 * w as usize + 3) * 4..][..4].try_into().unwrap());
let got = img.get_pixel(3, 2).0;
assert!(got[..3].iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 8), "depth {depth}: {got:?} vs {want:?}");
match depth {
32 => assert_eq!((img.get_pixel(0, 0).0[3], img.get_pixel(1, 0).0[3]), (0, 128), "8-bit alpha"),
24 => assert_eq!(img.get_pixel(0, 0).0, [255; 4], "flattened on white"),
_ => {}
}
}
assert!(tga::encode(&px, w, h, &TgaOptions { depth: 8 }).is_err());
assert!(tga::encode(&px, 70_000, 1, &TgaOptions::default()).is_err(), "too wide");
}
+77
View File
@@ -0,0 +1,77 @@
//! Targa export: uncompressed true colour at 16 bits per pixel (5-5-5 and a one-bit alpha), 24 or
//! 32 (8-bit alpha), rows bottom-up, with the version 2 footer.
use super::on_white;
/// Bits per pixel a Targa export can have.
pub const DEPTHS: [u8; 3] = [16, 24, 32];
/// The Targa options of a raster export.
#[derive(Clone, Debug, PartialEq)]
pub struct TgaOptions {
/// Bits per pixel ([`DEPTHS`]).
pub depth: u8,
}
impl Default for TgaOptions {
fn default() -> Self {
Self { depth: 24 }
}
}
impl TgaOptions {
/// Why these options can't be written, if they can't.
pub fn check(&self) -> Result<(), String> {
if DEPTHS.contains(&self.depth) { Ok(()) } else { Err(format!("Targa depth {}: 16, 24 or 32 bits per pixel", self.depth)) }
}
}
/// Straight-alpha RGBA pixels as a Targa file. 24 bits blend partly transparent pixels over white;
/// at 16 bits a pixel is opaque from half opacity up, else transparent.
pub fn encode(rgba: &[u8], width: u32, height: u32, o: &TgaOptions) -> Result<Vec<u8>, String> {
o.check()?;
let (Ok(w16), Ok(h16)) = (u16::try_from(width), u16::try_from(height)) else {
return Err(format!("{width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution"));
};
let (w, h) = (usize::from(w16), usize::from(h16));
if w == 0 || h == 0 || w * h * 4 != rgba.len() {
return Err("Targa encoding failed: the pixel buffer doesn't match the image size".into());
}
let px = rgba.as_chunks::<4>().0;
let mut out = Vec::with_capacity(18 + w * h * usize::from(o.depth / 8) + 26);
// No image id or colour map, uncompressed true colour, origin (0, 0).
out.extend([0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
out.extend(w16.to_le_bytes());
out.extend(h16.to_le_bytes());
// Bits per pixel, then the alpha bits (rows bottom-up, left to right).
let alpha_bits = match o.depth {
16 => 1,
32 => 8,
_ => 0,
};
out.extend([o.depth, alpha_bits]);
for row in px.chunks(w).rev() {
for p in row {
match o.depth {
16 => {
let v = if p[3] >= 128 {
let [r, g, b] = [p[0], p[1], p[2]].map(|c| u16::from(c >> 3));
0x8000 | r << 10 | g << 5 | b
} else {
0
};
out.extend(v.to_le_bytes());
}
24 => {
let [r, g, b] = on_white(p);
out.extend([b, g, r]);
}
_ => out.extend([p[2], p[1], p[0], p[3]]),
}
}
}
// No extension area or developer directory.
out.extend([0; 8]);
out.extend(b"TRUEVISION-XFILE.\0");
Ok(out)
}