// Copyright (c) 2025 lws
// TGA (Truevision Targa) image decoder
//
// TGA is a simple raster graphics file format widely used in games and VFX.
// Supported formats:
// - Type 2: Uncompressed true-color (RGB/RGBA, 24/32-bit)
// - Type 3: Uncompressed grayscale (8-bit)
// - Type 10: RLE compressed true-color (24/32-bit)
// - Type 11: RLE compressed grayscale (8-bit)
// - 16-bit A1R5G5B5 format
// - Top-left and bottom-left image origin
///|
/// Decode a TGA image from raw bytes
pub fn decode_tga(data : Bytes) -> Image raise Failure {
let decoder = TgaDecoder::new(data)
decoder.decode()
}
///|
/// TGA decoder state
priv struct TgaDecoder {
data : Bytes
width : Int
height : Int
pixel_depth : Int
image_descriptor : Int
data_offset : Int
rle : Bool
grayscale : Bool
}
///|
fn TgaDecoder::new(data : Bytes) -> TgaDecoder raise Failure {
if data.length() < 18 {
raise Failure::Failure("TGA: file too small for header")
}
let id_length = data[0].to_int()
let color_map_type = data[1].to_int()
let image_type = data[2].to_int()
// Read image specification
let width = read_u16_le(data, 12)
let height = read_u16_le(data, 14)
let pixel_depth = data[16].to_int()
let image_descriptor = data[17].to_int()
let grayscale = image_type == 3 || image_type == 11
if width <= 0 || height <= 0 {
raise Failure::Failure("TGA: invalid dimensions")
}
// Supported image types
let rle = match image_type {
2 => false // Uncompressed true-color
3 => false // Uncompressed grayscale
10 => true // RLE true-color
11 => true // RLE grayscale
_ => raise Failure::Failure("TGA: unsupported image type: \{image_type}")
}
// Supported pixel depths
let valid_depth = if grayscale {
pixel_depth == 8 || pixel_depth == 16
} else {
pixel_depth == 16 || pixel_depth == 24 || pixel_depth == 32
}
if !valid_depth {
raise Failure::Failure("TGA: unsupported pixel depth: \{pixel_depth}")
}
let attributes = image_descriptor & 15
let valid_attributes = match pixel_depth {
8 | 24 => attributes == 0
16 =>
if grayscale {
attributes == 8
} else {
attributes == 0 || attributes == 1
}
_ => attributes == 0 || attributes == 8
}
if !valid_attributes || (image_descriptor & 0xC0) != 0 {
raise Failure::Failure("TGA: unsupported image descriptor")
}
if color_map_type != 0 && color_map_type != 1 {
raise Failure::Failure("TGA: invalid color map type")
}
// Calculate data offset: header (18) + ID field + color map
let id_offset = 18 + id_length
let mut _color_map_size = 0
if color_map_type == 1 {
let _cm_first = read_u16_le(data, 3)
let cm_length = read_u16_le(data, 5)
let cm_entry_size = data[7].to_int()
if cm_length == 0 ||
!(cm_entry_size == 8 ||
cm_entry_size == 15 ||
cm_entry_size == 16 ||
cm_entry_size == 24 ||
cm_entry_size == 32) {
raise Failure::Failure("TGA: invalid color map")
}
_color_map_size = cm_length * ((cm_entry_size + 7) / 8)
}
let data_offset = id_offset + _color_map_size
if data_offset > data.length() {
raise Failure::Failure("TGA: truncated image ID or color map")
}
{
data,
width,
height,
pixel_depth,
image_descriptor,
data_offset,
rle,
grayscale,
}
}
///|
fn TgaDecoder::decode(self : TgaDecoder) -> Image raise Failure {
if self.rle {
self.decode_rle()
} else {
self.decode_uncompressed()
}
}
///|
/// Bytes per pixel for the source TGA data
fn TgaDecoder::src_bpp(self : TgaDecoder) -> Int {
self.pixel_depth / 8
}
///|
fn TgaDecoder::out_bpp(self : TgaDecoder) -> Int {
if self.grayscale {
self.pixel_depth / 8
} else {
4
}
}
///|
fn TgaDecoder::out_format(self : TgaDecoder) -> PixelFormat {
if self.grayscale {
if self.pixel_depth == 16 {
PixelFormat::GrayA8
} else {
PixelFormat::Gray8
}
} else {
PixelFormat::RGBA8
}
}
///|
fn TgaDecoder::src_column(self : TgaDecoder, column : Int) -> Int {
if (self.image_descriptor & 16) != 0 {
self.width - 1 - column
} else {
column
}
}
///|
/// Image origin: true = top-left, false = bottom-left
fn TgaDecoder::top_origin(self : TgaDecoder) -> Bool {
let origin_bits = (self.image_descriptor >> 4) & 0x3
origin_bits == 2 || origin_bits == 3
}
///|
/// Map destination row to source row in file
/// Output is always top-to-bottom. TGA can store top-to-bottom or bottom-to-top.
/// - If file is top-to-bottom (top_origin): src_row = dst_row (no flip needed)
/// - If file is bottom-to-top: src_row = height - 1 - dst_row (flip)
fn TgaDecoder::src_row(self : TgaDecoder, dst_row : Int) -> Int {
if self.top_origin() {
dst_row // File is already top-to-bottom, matches output order
} else {
self.height - 1 - dst_row // File is bottom-to-top, flip to match output
}
}
///|
/// Decode uncompressed TGA data
fn TgaDecoder::decode_uncompressed(self : TgaDecoder) -> Image raise Failure {
let src_bpp = self.src_bpp()
let out_bpp = self.out_bpp()
let out_size = checked_image_size(self.width, self.height, out_bpp)
// Row stride (TGA rows are tightly packed, no padding)
let src_stride = self.width * src_bpp
// Upfront bounds validation: total pixel data region must fit
if self.height * src_stride > self.data.length() - self.data_offset {
raise Failure::Failure("TGA: unexpected end of pixel data")
}
let _buf = Array::make(out_size, Byte::default())
for dst_y = 0; dst_y < self.height; dst_y = dst_y + 1 {
let src_y = self.src_row(dst_y)
let src_row_offset = self.data_offset + src_y * src_stride
for x = 0; x < self.width; x = x + 1 {
let src_pixel_offset = src_row_offset + self.src_column(x) * src_bpp
let dst_pixel_offset = (dst_y * self.width + x) * out_bpp
write_tga_pixel(
_buf,
dst_pixel_offset,
self.data,
src_pixel_offset,
self.pixel_depth,
self.grayscale,
(self.image_descriptor & 15) != 0,
)
}
}
Image::new(
self.width,
self.height,
self.out_format(),
Bytes::from_array(_buf),
)
}
///|
/// Decode RLE-compressed TGA data
/// RLE packets are read sequentially (not row-by-row), then scattered by row flipping
/// Uses incremental (x,y) tracking instead of per-pixel division/modulo
fn TgaDecoder::decode_rle(self : TgaDecoder) -> Image raise Failure {
let src_bpp = self.src_bpp()
let out_bpp = self.out_bpp()
let out_size = checked_image_size(self.width, self.height, out_bpp)
let total_pixels = out_size / out_bpp
if (total_pixels + 127) / 128 >
(self.data.length() - self.data_offset) / (src_bpp + 1) {
raise Failure::Failure("TGA: insufficient RLE data")
}
let _buf = Array::make(out_size, Byte::default())
let mut pixels_done = 0
let mut pos = self.data_offset
let mut cur_x = 0 // file-order column (0..width-1)
let mut cur_y = 0 // file-order row (0..height-1)
while pixels_done < total_pixels {
if pos >= self.data.length() {
raise Failure::Failure("TGA: unexpected end of RLE data")
}
let packet_header = self.data[pos].to_int()
pos = pos + 1
let packet_type = (packet_header >> 7) & 1 // 1 = RLE, 0 = raw
let count = (packet_header & 0x7F) + 1 // 1..128 pixels
if count > total_pixels - pixels_done {
raise Failure::Failure("TGA: RLE packet exceeds image pixel count")
}
if packet_type == 1 {
// RLE packet: repeat the next pixel `count` times
if src_bpp > self.data.length() - pos {
raise Failure::Failure("TGA: truncated RLE pixel data")
}
let pixel_data = self.data[pos:pos + src_bpp].to_owned()
for _i = 0; _i < count; _i = _i + 1 {
// Map file row to output row (file may be bottom-to-top)
let dst_y = self.src_row(cur_y)
let dst_pixel_offset = (dst_y * self.width + self.src_column(cur_x)) *
out_bpp
write_tga_pixel(
_buf,
dst_pixel_offset,
pixel_data,
0,
self.pixel_depth,
self.grayscale,
(self.image_descriptor & 15) != 0,
)
// Advance file-order position: increment x, wrap to next row
cur_x = cur_x + 1
if cur_x >= self.width {
cur_x = 0
cur_y = cur_y + 1
}
}
pos = pos + src_bpp
} else {
// Raw packet: read `count` pixels literally
for _i = 0; _i < count; _i = _i + 1 {
if src_bpp > self.data.length() - pos {
raise Failure::Failure("TGA: truncated raw pixel data")
}
// Map file row to output row
let dst_y = self.src_row(cur_y)
let dst_pixel_offset = (dst_y * self.width + self.src_column(cur_x)) *
out_bpp
write_tga_pixel(
_buf,
dst_pixel_offset,
self.data,
pos,
self.pixel_depth,
self.grayscale,
(self.image_descriptor & 15) != 0,
)
pos = pos + src_bpp
// Advance file-order position: increment x, wrap to next row
cur_x = cur_x + 1
if cur_x >= self.width {
cur_x = 0
cur_y = cur_y + 1
}
}
}
pixels_done = pixels_done + count
}
Image::new(
self.width,
self.height,
self.out_format(),
Bytes::from_array(_buf),
)
}
///|
/// Write a single pixel from TGA source data to output buffer
/// Callers (decode_rle / decode_uncompressed) validate bounds at packet/row level,
/// so per-pixel bounds check is redundant and omitted here
fn write_tga_pixel(
_buf : Array[Byte],
dst_offset : Int,
src : Bytes,
src_offset : Int,
pixel_depth : Int,
grayscale : Bool,
alpha : Bool,
) -> Unit {
if grayscale {
_buf[dst_offset] = src[src_offset]
if pixel_depth == 16 {
_buf[dst_offset + 1] = src[src_offset + 1]
}
} else if pixel_depth == 16 {
let lo = src[src_offset].to_int()
let hi = src[src_offset + 1].to_int()
let val = lo | (hi << 8)
let r = ((val >> 10) & 0x1F) * 255 / 31
let g = ((val >> 5) & 0x1F) * 255 / 31
let b = (val & 0x1F) * 255 / 31
let a = if !alpha || val >> 15 != 0 { 255 } else { 0 }
_buf[dst_offset] = r.to_byte()
_buf[dst_offset + 1] = g.to_byte()
_buf[dst_offset + 2] = b.to_byte()
_buf[dst_offset + 3] = a.to_byte()
} else if pixel_depth == 24 {
_buf[dst_offset] = src[src_offset + 2] // R
_buf[dst_offset + 1] = src[src_offset + 1] // G
_buf[dst_offset + 2] = src[src_offset] // B
_buf[dst_offset + 3] = b'\xFF' // A
} else {
// 32-bit BGRA → RGBA
_buf[dst_offset] = src[src_offset + 2] // R
_buf[dst_offset + 1] = src[src_offset + 1] // G
_buf[dst_offset + 2] = src[src_offset] // B
_buf[dst_offset + 3] = src[src_offset + 3] // A
}
()
}