|
| 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 | +} |
0 commit comments