Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Changelog

## 3.1.0 (unreleased)
## 3.1.0

### New

Expand All @@ -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

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
216 changes: 216 additions & 0 deletions src/codecs/tga.ts
Original file line number Diff line number Diff line change
@@ -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);
}
11 changes: 10 additions & 1 deletion src/image-type.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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));
}
}
15 changes: 12 additions & 3 deletions src/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Uint8Array> {
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
Expand All @@ -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);
Expand Down
75 changes: 75 additions & 0 deletions tests/tga.js
Original file line number Diff line number Diff line change
@@ -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}`);
}
})();
Loading