From e8eb0c2202490d0e450df647ca9cfbbf96aba338 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Thu, 15 Oct 2026 07:51:56 +0000 Subject: [PATCH 1/2] 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 Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J --- CHANGELOG.md | 3 + README.md | 4 +- src/codecs/tga.ts | 216 ++++++++++++++++++++++++++++++++++++++++++++++ src/image-type.ts | 11 ++- src/image.ts | 15 +++- tests/tga.js | 75 ++++++++++++++++ 6 files changed, 319 insertions(+), 5 deletions(-) create mode 100644 src/codecs/tga.ts create mode 100644 tests/tga.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 1154a81a..e06570c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ - **`Animation`**, the base class of `GIF`: an array of frames that decodes GIF, APNG and WebP (`Animation.decode()`) and encodes `encodeGIF()`, `encodeAPNG()` and `encodeWebP()`. `GIF` keeps its API. - `decode()` returns animated WebPs as `Animation`; `ImageType` recognizes WebP (`isWebP()`, `getType() === 'webp'`). +- **TGA** decoding and encoding (#21): color mapped, truecolor and greyscale, uncompressed and run length encoded, all + four origins. `Image.decode()` recognizes TGAs by their header (they have no magic number), `encodeTGA()` writes + run length encoded 24/32 bit files. ### Deprecated diff --git a/README.md b/README.md index 6ee78621..f67e14d1 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too) - JPEGs (baseline and progressive; grayscale, RGB, CMYK) - TIFFs (strips and tiles; uncompressed, LZW, deflate, PackBits and JPEG compressed; BigTIFF) - WebPs (lossy, lossless and with alpha; bit-exact with libwebp) + - TGAs (color mapped, truecolor and greyscale; uncompressed and run length encoded) - JPEGs, TIFFs, PNGs and WebPs are rotated as their Exif (or, for PNGs, XMP) orientation says - [Decoding animations](https://imagescript.matmen.dev/classes/Animation.html#decode) (GIF, APNG and WebP) with all frames, durations and disposal modes @@ -46,7 +47,8 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too) , [dominantColor](https://imagescript.matmen.dev/classes/Image.html#dominantcolor), ...) - Encoding images as [PNGs](https://imagescript.matmen.dev/classes/Image.html#encode) (smallest lossless color type) , [JPEGs](https://imagescript.matmen.dev/classes/Image.html#encodejpeg) - and lossless [WebPs](https://imagescript.matmen.dev/classes/Image.html#encodewebp) + , lossless [WebPs](https://imagescript.matmen.dev/classes/Image.html#encodewebp) + and [TGAs](https://imagescript.matmen.dev/classes/Image.html#encodetga) - Encoding animations as [GIF](https://imagescript.matmen.dev/classes/Animation.html#encodegif), [APNG](https://imagescript.matmen.dev/classes/Animation.html#encodeapng) and lossless [WebP](https://imagescript.matmen.dev/classes/Animation.html#encodewebp) diff --git a/src/codecs/tga.ts b/src/codecs/tga.ts new file mode 100644 index 00000000..edcb0136 --- /dev/null +++ b/src/codecs/tga.ts @@ -0,0 +1,216 @@ +// Truevision TGA: color mapped, truecolor and greyscale images, uncompressed or run length encoded + +const SIGNATURE = 'TRUEVISION-XFILE.\0'; + +interface Header { + idLength: number; + colorMapType: number; + imageType: number; + mapFirst: number; + mapLength: number; + mapEntrySize: number; + width: number; + height: number; + depth: number; + alphaBits: number; + rightToLeft: boolean; + topToBottom: boolean; +} + +function readHeader(d: Uint8Array): Header | null { + if (d.length < 18) return null; + return { + idLength: d[0], + colorMapType: d[1], + imageType: d[2], + mapFirst: d[3] | d[4] << 8, + mapLength: d[5] | d[6] << 8, + mapEntrySize: d[7], + width: d[12] | d[13] << 8, + height: d[14] | d[15] << 8, + depth: d[16], + alphaBits: d[17] & 15, + rightToLeft: (d[17] & 16) !== 0, + topToBottom: (d[17] & 32) !== 0 + }; +} + +/** Whether the data has the TGA 2.0 footer */ +function hasFooter(d: Uint8Array): boolean { + if (d.length < 44) return false; + for (let i = 0; i < SIGNATURE.length; i++) if (d[d.length - 18 + i] !== SIGNATURE.charCodeAt(i)) return false; + return true; +} + +/** + * Whether the data looks like a TGA. Older TGAs have no signature, so this checks that the header is consistent + */ +export function isTGA(d: Uint8Array): boolean { + const h = readHeader(d); + if (!h) return false; + if (hasFooter(d)) return true; + + const base = h.imageType & ~8; + if (h.colorMapType > 1 || ![1, 2, 3].includes(base) || h.imageType > 11 || (h.imageType & 4)) return false; + if (!h.width || !h.height || d[17] & 0xc0) return false; + if (base === 1 && (h.colorMapType !== 1 || (h.depth !== 8 && h.depth !== 16))) return false; + if (base === 2 && ![15, 16, 24, 32].includes(h.depth)) return false; + if (base === 3 && h.depth !== 8 && h.depth !== 16) return false; + if (h.colorMapType === 1 && ![8, 15, 16, 24, 32].includes(h.mapEntrySize)) return false; + + // the image data has to fit: exactly for uncompressed images, at least a byte per packet for RLE + const mapBytes = h.colorMapType ? h.mapLength * Math.ceil(h.mapEntrySize / 8) : 0; + const dataStart = 18 + h.idLength + mapBytes; + const pixelBytes = h.width * h.height * Math.ceil(h.depth / 8); + return h.imageType & 8 ? d.length > dataStart : d.length >= dataStart + pixelBytes; +} + +// reads a 15/16/24/32 bit color (BGR(A), 5 bits per channel for 15/16), or 8 bit grey (color maps) +function readColor(d: Uint8Array, o: number, bits: number, alphaBits: number, out: Uint8Array, p: number): void { + if (bits === 8) { + out[p] = out[p + 1] = out[p + 2] = d[o]; + out[p + 3] = 255; + } else if (bits === 15 || bits === 16) { + // the 16th bit is an "attribute" bit, writers fill it inconsistently (zero, inverted alpha, or unrelated): + // like image-rs, it is ignored + const v = d[o] | d[o + 1] << 8; + out[p] = (v >> 10 & 31) * 255 / 31 | 0; + out[p + 1] = (v >> 5 & 31) * 255 / 31 | 0; + out[p + 2] = (v & 31) * 255 / 31 | 0; + out[p + 3] = 255; + } else { + out[p] = d[o + 2]; + out[p + 1] = d[o + 1]; + out[p + 2] = d[o]; + out[p + 3] = bits === 32 && alphaBits ? d[o + 3] : 255; + } +} + +/** + * Decodes a TGA into RGBA + */ +export function decode(d: Uint8Array): { width: number, height: number, pixels: Uint8Array } { + const h = readHeader(d); + if (!h || !isTGA(d)) throw new Error('TGA: invalid header'); + const { width, height, depth } = h; + const base = h.imageType & ~8, rle = (h.imageType & 8) !== 0; + const bytesPerPixel = Math.ceil(depth / 8); + + let offset = 18 + h.idLength; + let palette: Uint8Array | null = null; + if (h.colorMapType === 1) { + const entryBytes = Math.ceil(h.mapEntrySize / 8); + palette = new Uint8Array((h.mapFirst + h.mapLength) * 4); + for (let i = 0; i < h.mapLength; i++) { + if (offset + entryBytes > d.length) throw new Error('TGA: truncated color map'); + readColor(d, offset, h.mapEntrySize, h.alphaBits, palette, (h.mapFirst + i) * 4); + offset += entryBytes; + } + } + if (base === 1 && !palette) throw new Error('TGA: color mapped image without color map'); + + // the pixel data in file order, expanded to RGBA + const count = width * height; + const stored = new Uint8Array(count * 4); + const pixel = (o: number, p: number): void => { + if (base === 1) { + const index = depth === 16 ? d[o] | d[o + 1] << 8 : d[o]; + const pal = palette as Uint8Array; + if (index * 4 + 3 < pal.length) stored.set(pal.subarray(index * 4, index * 4 + 4), p); + else stored[p + 3] = 255; // out of range indices become black + } else if (base === 3) { + stored[p] = stored[p + 1] = stored[p + 2] = d[o]; + stored[p + 3] = depth === 16 ? d[o + 1] : 255; + } else readColor(d, o, depth, h.alphaBits, stored, p); + }; + + if (!rle) { + if (offset + count * bytesPerPixel > d.length) throw new Error('TGA: truncated image data'); + for (let i = 0; i < count; i++) pixel(offset + i * bytesPerPixel, i * 4); + } else { + // packets: high bit set = one pixel repeated (n & 127) + 1 times, otherwise (n + 1) raw pixels; + // packets may cross scan lines + for (let i = 0; i < count;) { + if (offset >= d.length) throw new Error('TGA: truncated image data'); + const header = d[offset++]; + const n = (header & 127) + 1; + if (offset + (header & 128 ? 1 : n) * bytesPerPixel > d.length) throw new Error('TGA: truncated image data'); + if (header & 128) { + pixel(offset, i * 4); + for (let k = 1; k < n && i + k < count; k++) stored.copyWithin((i + k) * 4, i * 4, i * 4 + 4); + offset += bytesPerPixel; + } else { + for (let k = 0; k < n && i + k < count; k++) pixel(offset + k * bytesPerPixel, (i + k) * 4); + offset += n * bytesPerPixel; + } + i += n; + } + } + + // the default origin is the bottom left + if (!h.topToBottom || h.rightToLeft) { + const pixels = new Uint8Array(count * 4); + const src = new Uint32Array(stored.buffer), dst = new Uint32Array(pixels.buffer); + for (let y = 0; y < height; y++) { + const sy = h.topToBottom ? y : height - 1 - y; + for (let x = 0; x < width; x++) dst[y * width + x] = src[sy * width + (h.rightToLeft ? width - 1 - x : x)]; + } + return { width, height, pixels }; + } + + return { width, height, pixels: stored }; +} + +/** + * Encodes RGBA pixels into a run length encoded TGA (32 bit with alpha, 24 bit if opaque), top left origin + */ +export function encode(rgba: Uint8Array, width: number, height: number): Uint8Array { + if (width < 1 || height < 1 || width > 65535 || height > 65535) throw new RangeError('TGA: dimensions must be 1-65535'); + + let opaque = true; + for (let i = 3; i < rgba.length; i += 4) if (rgba[i] !== 255) { opaque = false; break; } + const bpp = opaque ? 3 : 4; + const count = width * height; + if (rgba.byteOffset % 4) rgba = rgba.slice(0, count * 4); + const pixels = new Uint32Array(rgba.buffer, rgba.byteOffset, count); + + // worst case: a header byte per 128 raw pixels + const out = new Uint8Array(18 + count * bpp + Math.ceil(count / 128) + 26); + out[2] = 10; // RLE truecolor + out[12] = width & 255; out[13] = width >> 8; + out[14] = height & 255; out[15] = height >> 8; + out[16] = bpp * 8; + out[17] = 32 | (opaque ? 0 : 8); // top left origin, alpha bits + + let o = 18; + const put = (i: number): void => { + const p = i * 4; + out[o++] = rgba[p + 2]; + out[o++] = rgba[p + 1]; + out[o++] = rgba[p]; + if (bpp === 4) out[o++] = rgba[p + 3]; + }; + + for (let i = 0; i < count;) { + // a run of at least two equal pixels becomes a run packet, otherwise collect raw pixels until the next run + let run = 1; + while (run < 128 && i + run < count && pixels[i + run] === pixels[i]) run++; + if (run >= 2) { + out[o++] = 128 | run - 1; + put(i); + i += run; + continue; + } + + let raw = 1; + while (raw < 128 && i + raw < count && !(i + raw + 1 < count && pixels[i + raw] === pixels[i + raw + 1])) raw++; + out[o++] = raw - 1; + for (let k = 0; k < raw; k++) put(i + k); + i += raw; + } + + // TGA 2.0 footer without extension and developer areas, it makes the file recognizable + o += 8; + for (let i = 0; i < SIGNATURE.length; i++) out[o++] = SIGNATURE.charCodeAt(i); + return out.slice(0, o); +} diff --git a/src/image-type.ts b/src/image-type.ts index 85c3b465..fab18697 100644 --- a/src/image-type.ts +++ b/src/image-type.ts @@ -1,7 +1,8 @@ import { BinaryData, view as bytes } from './util/mem.ts'; +import { isTGA } from './codecs/tga.ts'; /** Image formats recognized by {@link ImageType.getType} */ -export type ImageTypeName = 'png' | 'jpeg' | 'tiff' | 'gif' | 'webp'; +export type ImageTypeName = 'png' | 'jpeg' | 'tiff' | 'gif' | 'webp' | 'tga'; const MAGIC_NUMBERS = { PNG: 0x89504e47, @@ -37,6 +38,8 @@ export class ImageType { if (this.isTIFF(view)) return 'tiff'; if (this.isGIF(view)) return 'gif'; if (this.isWebP(view)) return 'webp'; + // TGAs have no magic number, they are recognized by a consistent header (or the TGA 2.0 footer) + if (this.isTGA(view)) return 'tga'; return null; } @@ -71,4 +74,10 @@ export class ImageType { const view = dataView(data); return view.byteLength >= 12 && view.getUint32(0, false) === MAGIC_NUMBERS.RIFF && view.getUint32(8, false) === MAGIC_NUMBERS.WEBP; } + + /** Whether the data looks like a TGA (which has no magic number: its header has to be consistent) */ + static isTGA(data: BinaryData): boolean { + const view = dataView(data); + return isTGA(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)); + } } diff --git a/src/image.ts b/src/image.ts index ae0ce4f5..71730d9e 100644 --- a/src/image.ts +++ b/src/image.ts @@ -2,6 +2,7 @@ import * as png from './codecs/png.ts'; import * as jpeg from './codecs/jpeg.ts'; import * as tiff from './codecs/tiff.ts'; import * as webp from './codecs/webp.ts'; +import * as tga from './codecs/tga.ts'; import * as quantize from './codecs/quantize.ts'; import * as textRenderer from './render/text.ts'; import * as svgRenderer from './render/svg.ts'; @@ -1584,8 +1585,15 @@ export class Image { } /** - * Decodes an image (PNG, JPEG, TIFF or WebP). For APNGs this is the default image; animated WebPs and GIFs have - * no single image, decode those with {@link Animation.decode} + * Encodes the image as run length encoded TGA (32 bit, or 24 bit if the image is opaque) + */ + async encodeTGA(): Promise { + return tga.encode(this.__bytes__(), this.width, this.height); + } + + /** + * Decodes an image (PNG, JPEG, TIFF, WebP or TGA). For APNGs this is the default image; animated WebPs and GIFs + * have no single image, decode those with {@link Animation.decode} * @param input The binary data to decode * @param options See {@link DecodeOptions}. JPEGs, TIFFs and PNGs are rotated/mirrored as their orientation tag * (Exif, for PNGs also XMP) says by default: photos taken with a rotated camera are stored sideways @@ -1603,7 +1611,8 @@ export class Image { else if (ImageType.isWebP(dataView)) { if (webp.isAnimated(data)) throw new Error('Animated WebPs have to be decoded with Animation.decode()'); decoded = webp.decode(data); - } else throw new Error('Unsupported image type'); + } else if (ImageType.isTGA(dataView)) decoded = tga.decode(data); + else throw new Error('Unsupported image type'); const image = new Image(decoded.width, decoded.height); image.bitmap.set(decoded.pixels); diff --git a/tests/tga.js b/tests/tga.js new file mode 100644 index 00000000..d469ee13 --- /dev/null +++ b/tests/tga.js @@ -0,0 +1,75 @@ +const {Image, ImageType} = require('..'); + +const panic = message => { + console.error(message); + process.exit(1); +}; + +// builds a TGA: header fields, optional color map, pixel data +function tga({type, depth, width, height, descriptor = 0, map = null, mapFirst = 0, mapDepth = 0, id = []}, data) { + const header = [id.length, map ? 1 : 0, type, mapFirst & 255, mapFirst >> 8, map ? map.length / (mapDepth / 8) & 255 : 0, + map ? map.length / (mapDepth / 8) >> 8 : 0, mapDepth, 0, 0, 0, 0, width & 255, width >> 8, height & 255, height >> 8, depth, descriptor]; + return Uint8Array.from([...header, ...id, ...(map || []), ...data]); +} + +const rgba = (image, x, y) => image.getRGBAAt(x, y).join(); + +(async () => { + // uncompressed 24 bit, default bottom left origin: the first stored row is the bottom one + { + const file = tga({type: 2, depth: 24, width: 2, height: 2, id: [1, 2, 3]}, [ + 0, 0, 255, 0, 255, 0, // bottom row: red, green (BGR) + 255, 0, 0, 255, 255, 255 // top row: blue, white + ]); + if (ImageType.getType(file) !== 'tga') panic('TGA not recognized'); + const image = await Image.decode(file); + if (rgba(image, 0, 0) !== '0,0,255,255' || rgba(image, 1, 0) !== '255,255,255,255') panic('TGA: top row wrong'); + if (rgba(image, 0, 1) !== '255,0,0,255' || rgba(image, 1, 1) !== '0,255,0,255') panic('TGA: bottom row wrong'); + } + + // color mapped (with a first map index), top right origin + { + 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]); + const image = await Image.decode(file); + 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'); + } + + // run length encoded greyscale with alpha, packets crossing scan lines, top left origin + { + const file = tga({type: 11, depth: 16, width: 3, height: 2, descriptor: 0x28}, [ + 0x83, 10, 255, // 4x (10, opaque) + 0x01, 20, 128, 30, 0 // raw: (20, half), (30, transparent) + ]); + const image = await Image.decode(file); + const row = y => [0, 1, 2].map(x => rgba(image, x, y)).join(' '); + 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'); + } + + // 16 bit: 5 bits per channel, the attribute bit is ignored + { + const image = await Image.decode(tga({type: 2, depth: 16, width: 1, height: 1, descriptor: 1}, [0x1f, 0x80])); + if (rgba(image, 0, 0) !== '0,0,255,255') panic('TGA: 16 bit wrong'); + } + + // encoding round trips, opaque images are stored with 24 bits + for (const image of [ + new Image(50, 7).fill((x, y) => Image.rgbaToColor(x * 5, y * 30, (x >> 2) * 40, x % 3 ? 255 : 100)), + new Image(300, 2).fill(0x336699ff), + new Image(1, 1).fill(0x00000000) + ]) { + const encoded = await image.encodeTGA(); + if (ImageType.getType(encoded) !== 'tga') panic('encoded TGA not recognized'); + const back = await Image.decode(encoded); + if (!Buffer.from(back.bitmap).equals(Buffer.from(image.bitmap))) panic(`TGA: ${image.width}x${image.height} didn't round trip`); + } + const opaque = await new Image(4, 4).fill(0xff0000ff).encodeTGA(); + if (opaque[16] !== 24) panic('TGA: opaque images should be stored with 24 bits'); + + // truncated data is rejected, not read past + try { + await Image.decode(tga({type: 10, depth: 24, width: 4, height: 4}, [0x83, 1, 2, 3]).subarray(0, 21)); + panic('TGA: truncated data accepted'); + } catch (err) { + if (!/TGA|Unsupported/.test(err.message)) panic(`TGA: unexpected error ${err.message}`); + } +})(); From c427a62d1f716095dd3ad9725bc8991a87ad4082 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Thu, 15 Oct 2026 08:28:31 +0000 Subject: [PATCH 2/2] changelog: no "(unreleased)" after the version the release check doesn't remove it, it would end up in the release Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e06570c9..65260918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 3.1.0 (unreleased) +## 3.1.0 ### New