From 43217bf868a0c88f2912805fd74746c6db0fb0f6 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Thu, 15 Oct 2026 14:38:09 +0000 Subject: [PATCH 1/2] end TGA run length packets with the row TGA 2.0 requires packets not to span rows, and some readers reject such files: Pillow fails with "buffer overrun" on e.g. a solid 20x10 image. The worst case output size grows accordingly (a packet header per 128 pixels of a row). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J --- CHANGELOG.md | 7 +++++++ src/codecs/tga.ts | 12 +++++++----- tests/tga.js | 13 ++++++++++++- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92904f27..4a50dcf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 3.1.1 + +### Fixes + +- TGA encoding: run length encoded packets end with the row, as TGA 2.0 requires. Before, packets could span rows, + which some readers reject (Pillow fails with "buffer overrun"). + ## 3.1.0 ### New diff --git a/src/codecs/tga.ts b/src/codecs/tga.ts index edcb0136..d958c17c 100644 --- a/src/codecs/tga.ts +++ b/src/codecs/tga.ts @@ -174,8 +174,8 @@ export function encode(rgba: Uint8Array, width: number, height: number): Uint8Ar 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); + // worst case: a header byte per 128 raw pixels of a row + const out = new Uint8Array(18 + count * bpp + height * Math.ceil(width / 128) + 26); out[2] = 10; // RLE truecolor out[12] = width & 255; out[13] = width >> 8; out[14] = height & 255; out[15] = height >> 8; @@ -192,9 +192,11 @@ export function encode(rgba: Uint8Array, width: number, height: number): Uint8Ar }; 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 + // a run of at least two equal pixels becomes a run packet, otherwise collect raw pixels until the next run; + // packets end with the row (TGA 2.0 requires it, Pillow rejects packets across rows) + const end = (Math.floor(i / width) + 1) * width; let run = 1; - while (run < 128 && i + run < count && pixels[i + run] === pixels[i]) run++; + while (run < 128 && i + run < end && pixels[i + run] === pixels[i]) run++; if (run >= 2) { out[o++] = 128 | run - 1; put(i); @@ -203,7 +205,7 @@ export function encode(rgba: Uint8Array, width: number, height: number): Uint8Ar } let raw = 1; - while (raw < 128 && i + raw < count && !(i + raw + 1 < count && pixels[i + raw] === pixels[i + raw + 1])) raw++; + while (raw < 128 && i + raw < end && !(i + raw + 1 < end && pixels[i + raw] === pixels[i + raw + 1])) raw++; out[o++] = raw - 1; for (let k = 0; k < raw; k++) put(i + k); i += raw; diff --git a/tests/tga.js b/tests/tga.js index d469ee13..2db631c6 100644 --- a/tests/tga.js +++ b/tests/tga.js @@ -55,12 +55,23 @@ const rgba = (image, x, y) => image.getRGBAAt(x, y).join(); 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) + new Image(1, 1).fill(0x00000000), + // a header per pixel: one pixel rows, alternating + new Image(1, 300).fill((x, y) => y & 1 ? 0xffffffff : 0x000000ff) ]) { 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`); + + // packets don't cross rows + const bpp = encoded[16] / 8; + for (let o = 18, pixel = 0; pixel < image.width * image.height;) { + const n = (encoded[o] & 127) + 1; + if (Math.floor(pixel / image.width) !== Math.floor((pixel + n - 1) / image.width)) panic(`TGA: ${image.width}x${image.height} has a packet across rows`); + o += 1 + (encoded[o] & 128 ? bpp : n * bpp); + pixel += n; + } } const opaque = await new Image(4, 4).fill(0xff0000ff).encodeTGA(); if (opaque[16] !== 24) panic('TGA: opaque images should be stored with 24 bits'); From fafc004d1e43d46b22fed06c5e69015768f3685c Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Thu, 15 Oct 2026 14:45:52 +0000 Subject: [PATCH 2/2] changelog: both changes go into 3.2.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J --- CHANGELOG.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a50dcf0..70bb0084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,27 @@ # Changelog -## 3.1.1 +## 3.2.0 + +### New + +- **Metadata in every format**: `encodeJPEG()`, `encodeWebP()`, `encodeTGA()` and, for animations, `encodeGIF()`, + `encodeAPNG()` and `encodeWebP()` take the same metadata as `encode()` (now called `Metadata`). Each format stores + the fields it has a place for: Exif and a comment for JPEG, Exif for WebP, comments for GIF, the extension area for + TGA, text chunks for PNG and APNG. Like PNGs, all files name ImageScript and its version as the software and + include the creation time by default; `{software: ''}` leaves the software out. + +### Deprecated + +- **`PNGMetadata`**: renamed to `Metadata`, the old name stays an alias until 4.0. ### Fixes - TGA encoding: run length encoded packets end with the row, as TGA 2.0 requires. Before, packets could span rows, which some readers reject (Pillow fails with "buffer overrun"). +- PNG text metadata: text that isn't Latin-1 (like `©` is, but `Ⓒ` or emoji aren't) is written into `iTXt` chunks as + UTF-8 instead of `tEXt`, where readers showed it garbled; text chunks come before the image data, where all readers + look for them. +- An invalid `creationTime` throws a `RangeError` instead of writing "Invalid Date". ## 3.1.0