rakata_formats/tga/
reader.rs

1//! TGA (Targa) reader.
2
3use std::io::Read;
4
5use tinytga::{Bpp, Compression, DataType, ImageOrigin, RawTga};
6
7use super::{
8    checked_pixel_count, Tga, TgaBinaryError, TgaBitsPerPixel, TgaCompression, TgaDataType,
9    TgaHeader, TgaOrigin, TgaReadMode, TgaReadOptions,
10};
11
12/// Reads TGA data from a reader.
13#[cfg_attr(
14    feature = "tracing",
15    tracing::instrument(level = "debug", skip(reader))
16)]
17pub fn read_tga<R: Read>(reader: &mut R) -> Result<Tga, TgaBinaryError> {
18    read_tga_with_options(reader, TgaReadOptions::default())
19}
20
21/// Reads TGA data from a reader with explicit options.
22#[cfg_attr(
23    feature = "tracing",
24    tracing::instrument(level = "debug", skip(reader, options))
25)]
26pub fn read_tga_with_options<R: Read>(
27    reader: &mut R,
28    options: TgaReadOptions,
29) -> Result<Tga, TgaBinaryError> {
30    let mut bytes = Vec::new();
31    reader.read_to_end(&mut bytes)?;
32    read_tga_from_bytes_with_options(&bytes, options)
33}
34
35/// Reads TGA data from bytes.
36#[cfg_attr(
37    feature = "tracing",
38    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
39)]
40pub fn read_tga_from_bytes(bytes: &[u8]) -> Result<Tga, TgaBinaryError> {
41    read_tga_from_bytes_with_options(bytes, TgaReadOptions::default())
42}
43
44/// Reads TGA data from bytes with explicit options.
45#[cfg_attr(
46    feature = "tracing",
47    tracing::instrument(level = "debug", skip(bytes, options), fields(bytes_len = bytes.len()))
48)]
49pub fn read_tga_from_bytes_with_options(
50    bytes: &[u8],
51    options: TgaReadOptions,
52) -> Result<Tga, TgaBinaryError> {
53    let raw = RawTga::from_slice(bytes)?;
54    let source_header = raw.header();
55
56    if source_header.width == 0 || source_header.height == 0 {
57        return Err(TgaBinaryError::InvalidHeader(
58            "width/height must be non-zero".into(),
59        ));
60    }
61
62    if matches!(source_header.data_type, DataType::NoData) {
63        return Err(TgaBinaryError::InvalidHeader(
64            "image declares no pixel data".into(),
65        ));
66    }
67
68    validate_read_header_mode(&source_header, options.input)?;
69    validate_uncompressed_source_size(&raw)?;
70
71    let width = usize::from(source_header.width);
72    let height = usize::from(source_header.height);
73    let pixel_count = checked_pixel_count(width, height)?;
74    let mut rgba = vec![0_u8; pixel_count * 4];
75
76    for raw_pixel in raw.pixels() {
77        let mut x = usize::try_from(raw_pixel.position.x)
78            .map_err(|_| TgaBinaryError::InvalidData("pixel x position out of range".into()))?;
79        let y = usize::try_from(raw_pixel.position.y)
80            .map_err(|_| TgaBinaryError::InvalidData("pixel y position out of range".into()))?;
81
82        if x >= width || y >= height {
83            return Err(TgaBinaryError::InvalidData(
84                "pixel position outside declared bounds".into(),
85            ));
86        }
87
88        if matches!(
89            source_header.image_origin,
90            ImageOrigin::TopRight | ImageOrigin::BottomRight
91        ) {
92            x = width - 1 - x;
93        }
94
95        let source_color = resolve_raw_pixel_color(&raw, &source_header, raw_pixel.color)?;
96        let rgba_pixel = decode_raw_color(
97            source_color,
98            raw.color_bpp(),
99            source_header.alpha_channel_depth,
100        )?;
101
102        let offset = (y * width + x)
103            .checked_mul(4)
104            .ok_or(TgaBinaryError::ValueOverflow("rgba offset"))?;
105        rgba[offset..offset + 4].copy_from_slice(&rgba_pixel);
106    }
107
108    let header = map_header(source_header)?;
109    let image_id = raw.image_id().unwrap_or(&[]).to_vec();
110
111    Ok(Tga {
112        header,
113        image_id,
114        rgba_pixels: rgba,
115    })
116}
117
118fn validate_read_header_mode(
119    header: &tinytga::TgaHeader,
120    mode: TgaReadMode,
121) -> Result<(), TgaBinaryError> {
122    if matches!(mode, TgaReadMode::Compatibility) {
123        return Ok(());
124    }
125
126    let supported_type = matches!(
127        (header.data_type, header.compression),
128        (DataType::ColorMapped, Compression::Uncompressed)
129            | (DataType::ColorMapped, Compression::Rle)
130            | (DataType::TrueColor, Compression::Uncompressed)
131            | (DataType::TrueColor, Compression::Rle)
132            | (DataType::BlackAndWhite, Compression::Uncompressed)
133    );
134    if !supported_type {
135        return Err(TgaBinaryError::InvalidHeader(
136            "canonical K1 reader rejects unsupported TGA image type".into(),
137        ));
138    }
139
140    if !matches!(header.pixel_depth, Bpp::Bits8 | Bpp::Bits24 | Bpp::Bits32) {
141        return Err(TgaBinaryError::InvalidHeader(
142            "canonical K1 reader only supports 8/24/32-bit source pixel depth".into(),
143        ));
144    }
145
146    if matches!(
147        (header.data_type, header.compression),
148        (DataType::TrueColor, Compression::Rle)
149    ) && !matches!(header.pixel_depth, Bpp::Bits24 | Bpp::Bits32)
150    {
151        return Err(TgaBinaryError::InvalidHeader(
152            "canonical K1 reader requires true-color RLE payloads to be 24 or 32-bit".into(),
153        ));
154    }
155
156    Ok(())
157}
158
159fn map_header(source: tinytga::TgaHeader) -> Result<TgaHeader, TgaBinaryError> {
160    Ok(TgaHeader {
161        id_len: source.id_len,
162        has_color_map: source.has_color_map,
163        data_type: map_data_type(source.data_type),
164        compression: map_compression(source.compression),
165        color_map_start: source.color_map_start,
166        color_map_len: source.color_map_len,
167        color_map_depth: source
168            .color_map_depth
169            .map(TgaBitsPerPixel::try_from_tinytga)
170            .transpose()?,
171        x_origin: source.x_origin,
172        y_origin: source.y_origin,
173        width: source.width,
174        height: source.height,
175        pixel_depth: TgaBitsPerPixel::try_from_tinytga(source.pixel_depth)?,
176        image_origin: map_origin(source.image_origin),
177        alpha_channel_depth: source.alpha_channel_depth,
178    })
179}
180
181fn map_data_type(data_type: DataType) -> TgaDataType {
182    match data_type {
183        DataType::NoData => TgaDataType::NoData,
184        DataType::ColorMapped => TgaDataType::ColorMapped,
185        DataType::TrueColor => TgaDataType::TrueColor,
186        DataType::BlackAndWhite => TgaDataType::BlackAndWhite,
187    }
188}
189
190fn map_compression(compression: Compression) -> TgaCompression {
191    match compression {
192        Compression::Uncompressed => TgaCompression::Uncompressed,
193        Compression::Rle => TgaCompression::Rle,
194    }
195}
196
197fn map_origin(origin: ImageOrigin) -> TgaOrigin {
198    match origin {
199        ImageOrigin::BottomLeft => TgaOrigin::BottomLeft,
200        ImageOrigin::BottomRight => TgaOrigin::BottomRight,
201        ImageOrigin::TopLeft => TgaOrigin::TopLeft,
202        ImageOrigin::TopRight => TgaOrigin::TopRight,
203    }
204}
205
206fn resolve_raw_pixel_color(
207    raw: &RawTga<'_>,
208    source_header: &tinytga::TgaHeader,
209    raw_color: u32,
210) -> Result<u32, TgaBinaryError> {
211    let Some(color_map) = raw.color_map() else {
212        return Ok(raw_color);
213    };
214
215    let raw_index = usize::try_from(raw_color)
216        .map_err(|_| TgaBinaryError::InvalidData("color-map index overflow".into()))?;
217    let start = usize::from(source_header.color_map_start);
218    let length = usize::from(source_header.color_map_len);
219
220    let palette_index = raw_index.checked_sub(start).ok_or_else(|| {
221        TgaBinaryError::InvalidData(format!(
222            "color-map index {raw_index} below color_map_start {start}"
223        ))
224    })?;
225
226    if palette_index >= length {
227        return Err(TgaBinaryError::InvalidData(format!(
228            "color-map index {raw_index} out of range for length {length}"
229        )));
230    }
231
232    color_map.get_raw(palette_index).ok_or_else(|| {
233        TgaBinaryError::InvalidData(format!(
234            "missing color-map entry at palette index {palette_index}"
235        ))
236    })
237}
238
239/// Extracts bits `[shift .. shift+8)` from a `u32` as a `u8`.
240fn byte_at(value: u32, shift: u32) -> u8 {
241    u8::try_from((value >> shift) & 0xFF).expect("masked to 8 bits")
242}
243
244/// Extracts bits `[shift .. shift+5)` from a `u16` as a `u8`.
245fn bits5_at(value: u16, shift: u16) -> u8 {
246    u8::try_from((value >> shift) & 0x1F).expect("masked to 5 bits")
247}
248
249fn decode_raw_color(
250    raw_color: u32,
251    bpp: Bpp,
252    alpha_channel_depth: u8,
253) -> Result<[u8; 4], TgaBinaryError> {
254    match bpp {
255        Bpp::Bits8 => {
256            let v = byte_at(raw_color, 0);
257            Ok([v, v, v, 255])
258        }
259        Bpp::Bits16 => {
260            let value = u16::try_from(raw_color & 0xFFFF).expect("masked to 16 bits");
261            let blue = expand_5bit(bits5_at(value, 0));
262            let green = expand_5bit(bits5_at(value, 5));
263            let red = expand_5bit(bits5_at(value, 10));
264            let alpha = if alpha_channel_depth > 0 && (value & 0x8000) == 0 {
265                0
266            } else {
267                255
268            };
269            Ok([red, green, blue, alpha])
270        }
271        Bpp::Bits24 => Ok([
272            byte_at(raw_color, 16),
273            byte_at(raw_color, 8),
274            byte_at(raw_color, 0),
275            255,
276        ]),
277        Bpp::Bits32 => Ok([
278            byte_at(raw_color, 16),
279            byte_at(raw_color, 8),
280            byte_at(raw_color, 0),
281            byte_at(raw_color, 24),
282        ]),
283        _ => Err(TgaBinaryError::InvalidData(
284            "unsupported bits-per-pixel value in decoded source data".into(),
285        )),
286    }
287}
288
289fn expand_5bit(value: u8) -> u8 {
290    (value << 3) | (value >> 2)
291}
292
293fn validate_uncompressed_source_size(raw: &RawTga<'_>) -> Result<(), TgaBinaryError> {
294    if !matches!(raw.compression(), Compression::Uncompressed) {
295        return Ok(());
296    }
297
298    let width =
299        usize::try_from(raw.size().width).map_err(|_| TgaBinaryError::ValueOverflow("width"))?;
300    let height =
301        usize::try_from(raw.size().height).map_err(|_| TgaBinaryError::ValueOverflow("height"))?;
302    let pixel_count = checked_pixel_count(width, height)?;
303    let expected_bytes = pixel_count
304        .checked_mul(usize::from(raw.image_data_bpp().bytes()))
305        .ok_or(TgaBinaryError::ValueOverflow(
306            "uncompressed image data bytes",
307        ))?;
308
309    if raw.image_data().len() < expected_bytes {
310        return Err(TgaBinaryError::InvalidData(format!(
311            "truncated uncompressed image data: expected at least {expected_bytes} bytes, got {}",
312            raw.image_data().len()
313        )));
314    }
315
316    Ok(())
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use crate::tga::write_tga_to_vec;
323
324    const CUBE_TGA: &[u8] = include_bytes!(concat!(
325        env!("CARGO_MANIFEST_DIR"),
326        "/../../fixtures/textures/cube/cm_506ond.tga"
327    ));
328
329    struct TestHeaderSpec {
330        color_map_type: u8,
331        image_type: u8,
332        color_map_start: u16,
333        color_map_len: u16,
334        color_map_depth: u8,
335        width: u16,
336        height: u16,
337        pixel_depth: u8,
338        descriptor: u8,
339    }
340
341    fn make_tga_header(spec: TestHeaderSpec) -> Vec<u8> {
342        let mut bytes = Vec::new();
343        bytes.extend_from_slice(&[0]); // id length
344        bytes.extend_from_slice(&[spec.color_map_type]);
345        bytes.extend_from_slice(&[spec.image_type]);
346        bytes.extend_from_slice(&spec.color_map_start.to_le_bytes());
347        bytes.extend_from_slice(&spec.color_map_len.to_le_bytes());
348        bytes.extend_from_slice(&[spec.color_map_depth]);
349        bytes.extend_from_slice(&0_u16.to_le_bytes()); // x origin
350        bytes.extend_from_slice(&0_u16.to_le_bytes()); // y origin
351        bytes.extend_from_slice(&spec.width.to_le_bytes());
352        bytes.extend_from_slice(&spec.height.to_le_bytes());
353        bytes.extend_from_slice(&[spec.pixel_depth]);
354        bytes.extend_from_slice(&[spec.descriptor]);
355        bytes
356    }
357
358    #[test]
359    fn parses_tga_fixture() {
360        let tga = read_tga_from_bytes(CUBE_TGA).expect("fixture should parse");
361
362        assert_eq!(tga.header.width, 4);
363        assert_eq!(tga.header.height, 4);
364        assert_eq!(tga.rgba_pixels.len(), 4 * 4 * 4);
365    }
366
367    #[test]
368    fn roundtrip_canonical_rgba_tga() {
369        let mut tga = Tga::new_rgba(
370            2,
371            2,
372            vec![
373                255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 128,
374            ],
375        )
376        .expect("create tga");
377        tga.image_id = b"rakata-rs".to_vec();
378
379        let bytes = write_tga_to_vec(&tga).expect("write should succeed");
380        let parsed = read_tga_from_bytes(&bytes).expect("read should succeed");
381
382        assert_eq!(parsed.header.width, 2);
383        assert_eq!(parsed.header.height, 2);
384        assert_eq!(parsed.image_id, b"rakata-rs");
385        assert_eq!(parsed.rgba_pixels, tga.rgba_pixels);
386
387        // Canonical writer emits uncompressed true-color 32bpp top-left.
388        assert_eq!(bytes[2], 2);
389        assert_eq!(bytes[16], 32);
390        assert_eq!(bytes[17], 0x28);
391    }
392
393    #[test]
394    fn writer_is_deterministic_for_canonical_tga() {
395        let mut tga = Tga::new_rgba(
396            2,
397            2,
398            vec![
399                255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 128,
400            ],
401        )
402        .expect("create tga");
403        tga.image_id = b"rakata-rs".to_vec();
404
405        let first = write_tga_to_vec(&tga).expect("first write should succeed");
406        let second = write_tga_to_vec(&tga).expect("second write should succeed");
407        assert_eq!(first, second, "canonical TGA writer output drifted");
408    }
409
410    #[test]
411    fn rejects_truncated_header() {
412        let bytes = vec![0_u8; 17];
413        let err = read_tga_from_bytes(&bytes).expect_err("must fail");
414        assert!(matches!(
415            err,
416            TgaBinaryError::Parse(_) | TgaBinaryError::InvalidHeader(_)
417        ));
418    }
419
420    #[test]
421    fn normalizes_bottom_left_origin_to_top_left() {
422        let mut bytes = make_tga_header(TestHeaderSpec {
423            color_map_type: 0,
424            image_type: 2,
425            color_map_start: 0,
426            color_map_len: 0,
427            color_map_depth: 0,
428            width: 1,
429            height: 2,
430            pixel_depth: 24,
431            descriptor: 0x00,
432        });
433
434        // Stored as bottom row then top row for bottom-left origin.
435        bytes.extend_from_slice(&[255, 0, 0]); // blue (BGR)
436        bytes.extend_from_slice(&[0, 255, 255]); // yellow (BGR)
437
438        let tga = read_tga_from_bytes(&bytes).expect("parse should succeed");
439        assert_eq!(tga.rgba_pixels, vec![255, 255, 0, 255, 0, 0, 255, 255]);
440    }
441
442    #[test]
443    fn normalizes_top_right_origin_to_top_left() {
444        let mut bytes = make_tga_header(TestHeaderSpec {
445            color_map_type: 0,
446            image_type: 2,
447            color_map_start: 0,
448            color_map_len: 0,
449            color_map_depth: 0,
450            width: 2,
451            height: 1,
452            pixel_depth: 24,
453            descriptor: 0x30,
454        });
455
456        // Top-right origin: first pixel belongs to the right-most column.
457        bytes.extend_from_slice(&[0, 0, 255]); // red (BGR)
458        bytes.extend_from_slice(&[0, 255, 0]); // green (BGR)
459
460        let tga = read_tga_from_bytes(&bytes).expect("parse should succeed");
461        assert_eq!(
462            tga.rgba_pixels,
463            vec![0, 255, 0, 255, 255, 0, 0, 255],
464            "x-axis should be normalized to top-left row-major ordering"
465        );
466    }
467
468    #[test]
469    fn honors_color_map_start_index() {
470        // Type 1: uncompressed color-mapped image.
471        let mut bytes = make_tga_header(TestHeaderSpec {
472            color_map_type: 1,
473            image_type: 1,
474            color_map_start: 1,
475            color_map_len: 1,
476            color_map_depth: 24,
477            width: 1,
478            height: 1,
479            pixel_depth: 8,
480            descriptor: 0x20,
481        });
482
483        // One palette entry at logical index 1: red in BGR form.
484        bytes.extend_from_slice(&[0, 0, 255]);
485
486        // Pixel references logical palette index 1.
487        bytes.push(1);
488
489        let tga = read_tga_from_bytes(&bytes).expect("parse should succeed");
490        assert_eq!(tga.rgba_pixels, vec![255, 0, 0, 255]);
491    }
492
493    #[test]
494    fn rejects_truncated_uncompressed_pixel_data() {
495        let mut bytes = make_tga_header(TestHeaderSpec {
496            color_map_type: 0,
497            image_type: 2,
498            color_map_start: 0,
499            color_map_len: 0,
500            color_map_depth: 0,
501            width: 2,
502            height: 2,
503            pixel_depth: 24,
504            descriptor: 0x20,
505        });
506        bytes.extend_from_slice(&[0, 0, 0]); // too short; expected at least 12
507
508        let err = read_tga_from_bytes(&bytes).expect_err("must fail");
509        assert!(matches!(err, TgaBinaryError::InvalidData(_)));
510    }
511
512    #[test]
513    fn writer_rejects_mismatched_rgba_length() {
514        let tga = Tga {
515            header: TgaHeader {
516                id_len: 0,
517                has_color_map: false,
518                data_type: TgaDataType::TrueColor,
519                compression: TgaCompression::Uncompressed,
520                color_map_start: 0,
521                color_map_len: 0,
522                color_map_depth: None,
523                x_origin: 0,
524                y_origin: 0,
525                width: 2,
526                height: 2,
527                pixel_depth: TgaBitsPerPixel::Bits32,
528                image_origin: TgaOrigin::TopLeft,
529                alpha_channel_depth: 8,
530            },
531            image_id: Vec::new(),
532            rgba_pixels: vec![0_u8; 3],
533        };
534
535        let err = write_tga_to_vec(&tga).expect_err("must fail");
536        assert!(matches!(err, TgaBinaryError::InvalidData(_)));
537    }
538
539    #[test]
540    fn bits_enum_reports_depth_values() {
541        assert_eq!(TgaBitsPerPixel::Bits8.bits(), 8);
542        assert_eq!(TgaBitsPerPixel::Bits16.bits(), 16);
543        assert_eq!(TgaBitsPerPixel::Bits24.bits(), 24);
544        assert_eq!(TgaBitsPerPixel::Bits32.bits(), 32);
545    }
546
547    #[test]
548    fn canonical_output_when_pixels_edited() {
549        // Parse a non-canonical bottom-left 24bpp TGA, then edit a pixel.
550        let mut src_bytes = make_tga_header(TestHeaderSpec {
551            color_map_type: 0,
552            image_type: 2,
553            color_map_start: 0,
554            color_map_len: 0,
555            color_map_depth: 0,
556            width: 1,
557            height: 1,
558            pixel_depth: 24,
559            descriptor: 0x00, // non-canonical bottom-left
560        });
561        src_bytes.extend_from_slice(&[0, 0, 255]);
562
563        let mut tga = read_tga_from_bytes(&src_bytes).expect("parse should succeed");
564        tga.rgba_pixels[0] = 0; // edit pixel -- triggers canonical output
565
566        let written = write_tga_to_vec(&tga).expect("write should succeed");
567        // Canonical: image_type=2, pixel_depth=32, descriptor=0x28 (top-left, 8-bit alpha).
568        assert_eq!(written[2], 2, "canonical output must use image_type 2");
569        assert_eq!(written[16], 32, "canonical output must use 32bpp");
570        assert_eq!(
571            written[17], 0x28,
572            "canonical output must be top-left with 8-bit alpha"
573        );
574        assert_ne!(
575            written, src_bytes,
576            "edited output must not equal source bytes"
577        );
578    }
579
580    #[test]
581    fn compat_tga_uses_canonical_output() {
582        // When pixels ARE edited after a compat-mode parse, writer falls back to canonical.
583        let mut bytes = make_tga_header(TestHeaderSpec {
584            color_map_type: 0,
585            image_type: 11,
586            color_map_start: 0,
587            color_map_len: 0,
588            color_map_depth: 0,
589            width: 1,
590            height: 1,
591            pixel_depth: 8,
592            descriptor: 0x20,
593        });
594        bytes.extend_from_slice(&[0x80, 0x40]); // one-pixel grayscale RLE packet
595
596        let mut tga = read_tga_from_bytes_with_options(
597            &bytes,
598            TgaReadOptions {
599                input: TgaReadMode::Compatibility,
600            },
601        )
602        .expect("compat parse should succeed");
603        tga.rgba_pixels[0] ^= 1; // edit a pixel -- triggers canonical output
604
605        let written = write_tga_to_vec(&tga).expect("write should succeed");
606        assert_eq!(written[2], 2, "canonical output must use true-color type 2");
607        assert_eq!(written[16], 32, "canonical output must use 32bpp");
608        assert_eq!(
609            written[17], 0x28,
610            "canonical output must be top-left with 8-bit alpha"
611        );
612
613        let parsed = read_tga_from_bytes(&written).expect("canonical output should parse");
614        assert_eq!(parsed.rgba_pixels.len(), tga.rgba_pixels.len());
615    }
616
617    #[test]
618    fn canonical_reader_rejects_grayscale_rle_type11() {
619        let mut bytes = make_tga_header(TestHeaderSpec {
620            color_map_type: 0,
621            image_type: 11,
622            color_map_start: 0,
623            color_map_len: 0,
624            color_map_depth: 0,
625            width: 1,
626            height: 1,
627            pixel_depth: 8,
628            descriptor: 0x20,
629        });
630        bytes.extend_from_slice(&[0x80, 0x40]);
631
632        let err = read_tga_from_bytes(&bytes).expect_err("canonical parse must fail");
633        assert!(matches!(err, TgaBinaryError::InvalidHeader(_)));
634    }
635
636    #[test]
637    fn compatibility_reader_allows_grayscale_rle_type11() {
638        let mut bytes = make_tga_header(TestHeaderSpec {
639            color_map_type: 0,
640            image_type: 11,
641            color_map_start: 0,
642            color_map_len: 0,
643            color_map_depth: 0,
644            width: 1,
645            height: 1,
646            pixel_depth: 8,
647            descriptor: 0x20,
648        });
649        bytes.extend_from_slice(&[0x80, 0x40]);
650
651        let parsed = read_tga_from_bytes_with_options(
652            &bytes,
653            TgaReadOptions {
654                input: TgaReadMode::Compatibility,
655            },
656        )
657        .expect("compat parse must succeed");
658        assert_eq!(parsed.rgba_pixels.len(), 4);
659    }
660}