// 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
}
///|
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()
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
if pixel_depth != 8 &&
pixel_depth != 16 &&
pixel_depth != 24 &&
pixel_depth != 32 {
raise Failure::Failure("TGA: unsupported pixel depth: \{pixel_depth}")
}
// 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()
_color_map_size = cm_length * ((cm_entry_size + 7) / 8)
}
let data_offset = id_offset + _color_map_size
{ data, width, height, pixel_depth, image_descriptor, data_offset, rle }
}
///|
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
}
///|
/// 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 = if self.pixel_depth <= 8 { 1 } else { 4 }
let out_size = self.width * self.height * out_bpp
let _buf = Array::make(out_size, Byte::default())
// 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.data.length() < self.data_offset + self.height * src_stride {
raise Failure::Failure("TGA: unexpected end of pixel data")
}
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 + x * src_bpp
let dst_pixel_offset = (dst_y * self.width + x) * out_bpp
if self.pixel_depth == 8 {
// Grayscale
_buf[dst_pixel_offset] = self.data[src_pixel_offset]
} else if self.pixel_depth == 16 {
// 16-bit: A1R5G5B5 or grayscale+alpha
let lo = self.data[src_pixel_offset].to_int()
let hi = self.data[src_pixel_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 val >> 15 != 0 { 255 } else { 0 }
_buf[dst_pixel_offset] = r.to_byte()
_buf[dst_pixel_offset + 1] = g.to_byte()
_buf[dst_pixel_offset + 2] = b.to_byte()
_buf[dst_pixel_offset + 3] = a.to_byte()
} else if self.pixel_depth == 24 {
// BGR → RGB
_buf[dst_pixel_offset] = self.data[src_pixel_offset + 2] // R
_buf[dst_pixel_offset + 1] = self.data[src_pixel_offset + 1] // G
_buf[dst_pixel_offset + 2] = self.data[src_pixel_offset] // B
_buf[dst_pixel_offset + 3] = b'\xFF' // A
} else {
// 32-bit BGRA → RGBA
_buf[dst_pixel_offset] = self.data[src_pixel_offset + 2] // R
_buf[dst_pixel_offset + 1] = self.data[src_pixel_offset + 1] // G
_buf[dst_pixel_offset + 2] = self.data[src_pixel_offset] // B
_buf[dst_pixel_offset + 3] = self.data[src_pixel_offset + 3] // A
}
}
}
let format = if out_bpp == 1 {
PixelFormat::Gray8
} else {
PixelFormat::RGBA8
}
Image::new(self.width, self.height, 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 = if self.pixel_depth <= 8 { 1 } else { 4 }
let out_size = self.width * self.height * out_bpp
let _buf = Array::make(out_size, Byte::default())
let total_pixels = self.width * self.height
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 pos + src_bpp > self.data.length() {
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 + cur_x) * out_bpp
write_tga_pixel(_buf, dst_pixel_offset, pixel_data, 0, self.pixel_depth)
// 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 pos + src_bpp > self.data.length() {
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 + cur_x) * out_bpp
write_tga_pixel(
_buf,
dst_pixel_offset,
self.data,
pos,
self.pixel_depth,
)
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
}
let format = if out_bpp == 1 {
PixelFormat::Gray8
} else {
PixelFormat::RGBA8
}
Image::new(self.width, self.height, 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,
) -> Unit {
if pixel_depth == 8 {
_buf[dst_offset] = src[src_offset]
} 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 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
}
()
}