Skip to content

Commit 8dbcf22

Browse files
matmenclaude
andcommitted
tga decoding and encoding (#21)
Color mapped, truecolor and greyscale images, uncompressed and run length encoded, all four origins. TGAs have no magic number, ImageType recognizes them by a consistent header or the TGA 2.0 footer (no false positives over 2267 other image files). 16 bit files ignore the attribute bit like image-rs does, writers fill it inconsistently. encodeTGA() writes run length encoded 24 bit (opaque) or 32 bit files with the 2.0 footer. Matches Pillow on the Pillow and image-rs test files except for that bit, and also reads the image-rs files Pillow rejects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J
1 parent c50cc03 commit 8dbcf22

6 files changed

Lines changed: 321 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## 3.1.0 (unreleased)
4+
5+
### New
6+
7+
- **TGA** decoding and encoding (#21): color mapped, truecolor and greyscale, uncompressed and run length encoded, all
8+
four origins. `Image.decode()` recognizes TGAs by their header (they have no magic number), `encodeTGA()` writes
9+
run length encoded 24/32 bit files.
10+
311
## 3.0.0
412

513
ImageScript is now written in TypeScript and has no WebAssembly or native binaries anymore. Every codec and renderer

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
2525
- PNGs (grayscale, RGB, indexed colors, 1-16 bit, interlaced) with and without alpha channels
2626
- JPEGs (baseline and progressive; grayscale, RGB, CMYK)
2727
- TIFFs (strips and tiles; uncompressed, LZW, deflate, PackBits and JPEG compressed; BigTIFF)
28+
- TGAs (color mapped, truecolor and greyscale; uncompressed and run length encoded)
2829
- JPEGs, TIFFs and PNGs are rotated as their Exif (or, for PNGs, XMP) orientation says
2930
- [Decoding GIFs](https://imagescript.matmen.dev/classes/GIF.html#decode) with all frames, durations and disposal modes
3031
- [Rendering SVGs](https://imagescript.matmen.dev/classes/Image.html#rendersvg) (shapes, paths, gradients, strokes, clip paths,
@@ -44,6 +45,7 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
4445
, [dominantColor](https://imagescript.matmen.dev/classes/Image.html#dominantcolor), ...)
4546
- Encoding images as [PNGs](https://imagescript.matmen.dev/classes/Image.html#encode) (smallest lossless color type)
4647
, [JPEGs](https://imagescript.matmen.dev/classes/Image.html#encodejpeg)
48+
, [TGAs](https://imagescript.matmen.dev/classes/Image.html#encodetga)
4749
and animated [GIFs](https://imagescript.matmen.dev/classes/GIF.html#encode)
4850

4951
---

‎src/codecs/tga.ts‎

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// Truevision TGA: color mapped, truecolor and greyscale images, uncompressed or run length encoded
2+
3+
const SIGNATURE = 'TRUEVISION-XFILE.\0';
4+
5+
interface Header {
6+
idLength: number;
7+
colorMapType: number;
8+
imageType: number;
9+
mapFirst: number;
10+
mapLength: number;
11+
mapEntrySize: number;
12+
width: number;
13+
height: number;
14+
depth: number;
15+
alphaBits: number;
16+
rightToLeft: boolean;
17+
topToBottom: boolean;
18+
}
19+
20+
function readHeader(d: Uint8Array): Header | null {
21+
if (d.length < 18) return null;
22+
return {
23+
idLength: d[0],
24+
colorMapType: d[1],
25+
imageType: d[2],
26+
mapFirst: d[3] | d[4] << 8,
27+
mapLength: d[5] | d[6] << 8,
28+
mapEntrySize: d[7],
29+
width: d[12] | d[13] << 8,
30+
height: d[14] | d[15] << 8,
31+
depth: d[16],
32+
alphaBits: d[17] & 15,
33+
rightToLeft: (d[17] & 16) !== 0,
34+
topToBottom: (d[17] & 32) !== 0
35+
};
36+
}
37+
38+
/** Whether the data has the TGA 2.0 footer */
39+
function hasFooter(d: Uint8Array): boolean {
40+
if (d.length < 44) return false;
41+
for (let i = 0; i < SIGNATURE.length; i++) if (d[d.length - 18 + i] !== SIGNATURE.charCodeAt(i)) return false;
42+
return true;
43+
}
44+
45+
/**
46+
* Whether the data looks like a TGA. Older TGAs have no signature, so this checks that the header is consistent
47+
*/
48+
export function isTGA(d: Uint8Array): boolean {
49+
const h = readHeader(d);
50+
if (!h) return false;
51+
if (hasFooter(d)) return true;
52+
53+
const base = h.imageType & ~8;
54+
if (h.colorMapType > 1 || ![1, 2, 3].includes(base) || h.imageType > 11 || (h.imageType & 4)) return false;
55+
if (!h.width || !h.height || d[17] & 0xc0) return false;
56+
if (base === 1 && (h.colorMapType !== 1 || (h.depth !== 8 && h.depth !== 16))) return false;
57+
if (base === 2 && ![15, 16, 24, 32].includes(h.depth)) return false;
58+
if (base === 3 && h.depth !== 8 && h.depth !== 16) return false;
59+
if (h.colorMapType === 1 && ![8, 15, 16, 24, 32].includes(h.mapEntrySize)) return false;
60+
61+
// the image data has to fit: exactly for uncompressed images, at least a byte per packet for RLE
62+
const mapBytes = h.colorMapType ? h.mapLength * Math.ceil(h.mapEntrySize / 8) : 0;
63+
const dataStart = 18 + h.idLength + mapBytes;
64+
const pixelBytes = h.width * h.height * Math.ceil(h.depth / 8);
65+
return h.imageType & 8 ? d.length > dataStart : d.length >= dataStart + pixelBytes;
66+
}
67+
68+
// reads a 15/16/24/32 bit color (BGR(A), 5 bits per channel for 15/16), or 8 bit grey (color maps)
69+
function readColor(d: Uint8Array, o: number, bits: number, alphaBits: number, out: Uint8Array, p: number): void {
70+
if (bits === 8) {
71+
out[p] = out[p + 1] = out[p + 2] = d[o];
72+
out[p + 3] = 255;
73+
} else if (bits === 15 || bits === 16) {
74+
// the 16th bit is an "attribute" bit, writers fill it inconsistently (zero, inverted alpha, or unrelated):
75+
// like image-rs, it is ignored
76+
const v = d[o] | d[o + 1] << 8;
77+
out[p] = (v >> 10 & 31) * 255 / 31 | 0;
78+
out[p + 1] = (v >> 5 & 31) * 255 / 31 | 0;
79+
out[p + 2] = (v & 31) * 255 / 31 | 0;
80+
out[p + 3] = 255;
81+
} else {
82+
out[p] = d[o + 2];
83+
out[p + 1] = d[o + 1];
84+
out[p + 2] = d[o];
85+
out[p + 3] = bits === 32 && alphaBits ? d[o + 3] : 255;
86+
}
87+
}
88+
89+
/**
90+
* Decodes a TGA into RGBA
91+
*/
92+
export function decode(d: Uint8Array): { width: number, height: number, pixels: Uint8Array } {
93+
const h = readHeader(d);
94+
if (!h || !isTGA(d)) throw new Error('TGA: invalid header');
95+
const { width, height, depth } = h;
96+
const base = h.imageType & ~8, rle = (h.imageType & 8) !== 0;
97+
const bytesPerPixel = Math.ceil(depth / 8);
98+
99+
let offset = 18 + h.idLength;
100+
let palette: Uint8Array | null = null;
101+
if (h.colorMapType === 1) {
102+
const entryBytes = Math.ceil(h.mapEntrySize / 8);
103+
palette = new Uint8Array((h.mapFirst + h.mapLength) * 4);
104+
for (let i = 0; i < h.mapLength; i++) {
105+
if (offset + entryBytes > d.length) throw new Error('TGA: truncated color map');
106+
readColor(d, offset, h.mapEntrySize, h.alphaBits, palette, (h.mapFirst + i) * 4);
107+
offset += entryBytes;
108+
}
109+
}
110+
if (base === 1 && !palette) throw new Error('TGA: color mapped image without color map');
111+
112+
// the pixel data in file order, expanded to RGBA
113+
const count = width * height;
114+
const stored = new Uint8Array(count * 4);
115+
const pixel = (o: number, p: number): void => {
116+
if (base === 1) {
117+
const index = depth === 16 ? d[o] | d[o + 1] << 8 : d[o];
118+
const pal = palette as Uint8Array;
119+
if (index * 4 + 3 < pal.length) stored.set(pal.subarray(index * 4, index * 4 + 4), p);
120+
else stored[p + 3] = 255; // out of range indices become black
121+
} else if (base === 3) {
122+
stored[p] = stored[p + 1] = stored[p + 2] = d[o];
123+
stored[p + 3] = depth === 16 ? d[o + 1] : 255;
124+
} else readColor(d, o, depth, h.alphaBits, stored, p);
125+
};
126+
127+
if (!rle) {
128+
if (offset + count * bytesPerPixel > d.length) throw new Error('TGA: truncated image data');
129+
for (let i = 0; i < count; i++) pixel(offset + i * bytesPerPixel, i * 4);
130+
} else {
131+
// packets: high bit set = one pixel repeated (n & 127) + 1 times, otherwise (n + 1) raw pixels;
132+
// packets may cross scan lines
133+
for (let i = 0; i < count;) {
134+
if (offset >= d.length) throw new Error('TGA: truncated image data');
135+
const header = d[offset++];
136+
const n = (header & 127) + 1;
137+
if (offset + (header & 128 ? 1 : n) * bytesPerPixel > d.length) throw new Error('TGA: truncated image data');
138+
if (header & 128) {
139+
pixel(offset, i * 4);
140+
for (let k = 1; k < n && i + k < count; k++) stored.copyWithin((i + k) * 4, i * 4, i * 4 + 4);
141+
offset += bytesPerPixel;
142+
} else {
143+
for (let k = 0; k < n && i + k < count; k++) pixel(offset + k * bytesPerPixel, (i + k) * 4);
144+
offset += n * bytesPerPixel;
145+
}
146+
i += n;
147+
}
148+
}
149+
150+
// the default origin is the bottom left
151+
if (!h.topToBottom || h.rightToLeft) {
152+
const pixels = new Uint8Array(count * 4);
153+
const src = new Uint32Array(stored.buffer), dst = new Uint32Array(pixels.buffer);
154+
for (let y = 0; y < height; y++) {
155+
const sy = h.topToBottom ? y : height - 1 - y;
156+
for (let x = 0; x < width; x++) dst[y * width + x] = src[sy * width + (h.rightToLeft ? width - 1 - x : x)];
157+
}
158+
return { width, height, pixels };
159+
}
160+
161+
return { width, height, pixels: stored };
162+
}
163+
164+
/**
165+
* Encodes RGBA pixels into a run length encoded TGA (32 bit with alpha, 24 bit if opaque), top left origin
166+
*/
167+
export function encode(rgba: Uint8Array, width: number, height: number): Uint8Array {
168+
if (width < 1 || height < 1 || width > 65535 || height > 65535) throw new RangeError('TGA: dimensions must be 1-65535');
169+
170+
let opaque = true;
171+
for (let i = 3; i < rgba.length; i += 4) if (rgba[i] !== 255) { opaque = false; break; }
172+
const bpp = opaque ? 3 : 4;
173+
const count = width * height;
174+
if (rgba.byteOffset % 4) rgba = rgba.slice(0, count * 4);
175+
const pixels = new Uint32Array(rgba.buffer, rgba.byteOffset, count);
176+
177+
// worst case: a header byte per 128 raw pixels
178+
const out = new Uint8Array(18 + count * bpp + Math.ceil(count / 128) + 26);
179+
out[2] = 10; // RLE truecolor
180+
out[12] = width & 255; out[13] = width >> 8;
181+
out[14] = height & 255; out[15] = height >> 8;
182+
out[16] = bpp * 8;
183+
out[17] = 32 | (opaque ? 0 : 8); // top left origin, alpha bits
184+
185+
let o = 18;
186+
const put = (i: number): void => {
187+
const p = i * 4;
188+
out[o++] = rgba[p + 2];
189+
out[o++] = rgba[p + 1];
190+
out[o++] = rgba[p];
191+
if (bpp === 4) out[o++] = rgba[p + 3];
192+
};
193+
194+
for (let i = 0; i < count;) {
195+
// a run of at least two equal pixels becomes a run packet, otherwise collect raw pixels until the next run
196+
let run = 1;
197+
while (run < 128 && i + run < count && pixels[i + run] === pixels[i]) run++;
198+
if (run >= 2) {
199+
out[o++] = 128 | run - 1;
200+
put(i);
201+
i += run;
202+
continue;
203+
}
204+
205+
let raw = 1;
206+
while (raw < 128 && i + raw < count && !(i + raw + 1 < count && pixels[i + raw] === pixels[i + raw + 1])) raw++;
207+
out[o++] = raw - 1;
208+
for (let k = 0; k < raw; k++) put(i + k);
209+
i += raw;
210+
}
211+
212+
// TGA 2.0 footer without extension and developer areas, it makes the file recognizable
213+
o += 8;
214+
for (let i = 0; i < SIGNATURE.length; i++) out[o++] = SIGNATURE.charCodeAt(i);
215+
return out.slice(0, o);
216+
}

‎src/image-type.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { BinaryData, view as bytes } from './util/mem.ts';
2+
import { isTGA } from './codecs/tga.ts';
23

34
/** Image formats recognized by {@link ImageType.getType} */
4-
export type ImageTypeName = 'png' | 'jpeg' | 'tiff' | 'gif';
5+
export type ImageTypeName = 'png' | 'jpeg' | 'tiff' | 'gif' | 'tga';
56

67
const MAGIC_NUMBERS = {
78
PNG: 0x89504e47,
@@ -34,6 +35,8 @@ export class ImageType {
3435
if (this.isJPEG(view)) return 'jpeg';
3536
if (this.isTIFF(view)) return 'tiff';
3637
if (this.isGIF(view)) return 'gif';
38+
// TGAs have no magic number, they are recognized by a consistent header (or the TGA 2.0 footer)
39+
if (this.isTGA(view)) return 'tga';
3740
return null;
3841
}
3942

@@ -62,4 +65,10 @@ export class ImageType {
6265
const view = dataView(data);
6366
return view.byteLength >= 4 && (view.getUint32(0, false) >>> 8) === MAGIC_NUMBERS.GIF;
6467
}
68+
69+
/** Whether the data looks like a TGA (which has no magic number: its header has to be consistent) */
70+
static isTGA(data: BinaryData): boolean {
71+
const view = dataView(data);
72+
return isTGA(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
73+
}
6574
}

‎src/image.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as png from './codecs/png.ts';
22
import * as jpeg from './codecs/jpeg.ts';
33
import * as tiff from './codecs/tiff.ts';
4+
import * as tga from './codecs/tga.ts';
45
import * as quantize from './codecs/quantize.ts';
56
import * as textRenderer from './render/text.ts';
67
import * as svgRenderer from './render/svg.ts';
@@ -1576,7 +1577,14 @@ export class Image {
15761577
}
15771578

15781579
/**
1579-
* Decodes an image (PNG, JPEG or TIFF)
1580+
* Encodes the image as run length encoded TGA (32 bit, or 24 bit if the image is opaque)
1581+
*/
1582+
async encodeTGA(): Promise<Uint8Array> {
1583+
return tga.encode(this.__bytes__(), this.width, this.height);
1584+
}
1585+
1586+
/**
1587+
* Decodes an image (PNG, JPEG, TIFF or TGA)
15801588
* @param input The binary data to decode
15811589
* @param options See {@link DecodeOptions}. JPEGs, TIFFs and PNGs are rotated/mirrored as their orientation tag
15821590
* (Exif, for PNGs also XMP) says by default: photos taken with a rotated camera are stored sideways
@@ -1591,6 +1599,7 @@ export class Image {
15911599
if (ImageType.isPNG(dataView)) decoded = await png.decode(data);
15921600
else if (ImageType.isJPEG(dataView)) decoded = jpeg.decode(data);
15931601
else if (ImageType.isTIFF(dataView)) decoded = await tiff.decode(data);
1602+
else if (ImageType.isTGA(dataView)) decoded = tga.decode(data);
15941603
else throw new Error('Unsupported image type');
15951604

15961605
const image = new Image(decoded.width, decoded.height);

‎tests/tga.js‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
const {Image, ImageType} = require('..');
2+
3+
const panic = message => {
4+
console.error(message);
5+
process.exit(1);
6+
};
7+
8+
// builds a TGA: header fields, optional color map, pixel data
9+
function tga({type, depth, width, height, descriptor = 0, map = null, mapFirst = 0, mapDepth = 0, id = []}, data) {
10+
const header = [id.length, map ? 1 : 0, type, mapFirst & 255, mapFirst >> 8, map ? map.length / (mapDepth / 8) & 255 : 0,
11+
map ? map.length / (mapDepth / 8) >> 8 : 0, mapDepth, 0, 0, 0, 0, width & 255, width >> 8, height & 255, height >> 8, depth, descriptor];
12+
return Uint8Array.from([...header, ...id, ...(map || []), ...data]);
13+
}
14+
15+
const rgba = (image, x, y) => image.getRGBAAt(x, y).join();
16+
17+
(async () => {
18+
// uncompressed 24 bit, default bottom left origin: the first stored row is the bottom one
19+
{
20+
const file = tga({type: 2, depth: 24, width: 2, height: 2, id: [1, 2, 3]}, [
21+
0, 0, 255, 0, 255, 0, // bottom row: red, green (BGR)
22+
255, 0, 0, 255, 255, 255 // top row: blue, white
23+
]);
24+
if (ImageType.getType(file) !== 'tga') panic('TGA not recognized');
25+
const image = await Image.decode(file);
26+
if (rgba(image, 0, 0) !== '0,0,255,255' || rgba(image, 1, 0) !== '255,255,255,255') panic('TGA: top row wrong');
27+
if (rgba(image, 0, 1) !== '255,0,0,255' || rgba(image, 1, 1) !== '0,255,0,255') panic('TGA: bottom row wrong');
28+
}
29+
30+
// color mapped (with a first map index), top right origin
31+
{
32+
const file = tga({type: 1, depth: 8, width: 3, height: 1, descriptor: 0x30, mapFirst: 2, mapDepth: 24, map: [0, 0, 255, 0, 255, 0]}, [2, 3, 2]);
33+
const image = await Image.decode(file);
34+
if ([0, 1, 2].map(x => rgba(image, x, 0)).join(' ') !== '255,0,0,255 0,255,0,255 255,0,0,255') panic('TGA: color map wrong');
35+
}
36+
37+
// run length encoded greyscale with alpha, packets crossing scan lines, top left origin
38+
{
39+
const file = tga({type: 11, depth: 16, width: 3, height: 2, descriptor: 0x28}, [
40+
0x83, 10, 255, // 4x (10, opaque)
41+
0x01, 20, 128, 30, 0 // raw: (20, half), (30, transparent)
42+
]);
43+
const image = await Image.decode(file);
44+
const row = y => [0, 1, 2].map(x => rgba(image, x, y)).join(' ');
45+
if (row(0) !== '10,10,10,255 10,10,10,255 10,10,10,255' || row(1) !== '10,10,10,255 20,20,20,128 30,30,30,0') panic('TGA: RLE wrong');
46+
}
47+
48+
// 16 bit: 5 bits per channel, the attribute bit is ignored
49+
{
50+
const image = await Image.decode(tga({type: 2, depth: 16, width: 1, height: 1, descriptor: 1}, [0x1f, 0x80]));
51+
if (rgba(image, 0, 0) !== '0,0,255,255') panic('TGA: 16 bit wrong');
52+
}
53+
54+
// encoding round trips, opaque images are stored with 24 bits
55+
for (const image of [
56+
new Image(50, 7).fill((x, y) => Image.rgbaToColor(x * 5, y * 30, (x >> 2) * 40, x % 3 ? 255 : 100)),
57+
new Image(300, 2).fill(0x336699ff),
58+
new Image(1, 1).fill(0x00000000)
59+
]) {
60+
const encoded = await image.encodeTGA();
61+
if (ImageType.getType(encoded) !== 'tga') panic('encoded TGA not recognized');
62+
const back = await Image.decode(encoded);
63+
if (!Buffer.from(back.bitmap).equals(Buffer.from(image.bitmap))) panic(`TGA: ${image.width}x${image.height} didn't round trip`);
64+
}
65+
const opaque = await new Image(4, 4).fill(0xff0000ff).encodeTGA();
66+
if (opaque[16] !== 24) panic('TGA: opaque images should be stored with 24 bits');
67+
68+
// truncated data is rejected, not read past
69+
try {
70+
await Image.decode(tga({type: 10, depth: 24, width: 4, height: 4}, [0x83, 1, 2, 3]).subarray(0, 21));
71+
panic('TGA: truncated data accepted');
72+
} catch (err) {
73+
if (!/TGA|Unsupported/.test(err.message)) panic(`TGA: unexpected error ${err.message}`);
74+
}
75+
})();

0 commit comments

Comments
 (0)