Skip to content

Commit a565556

Browse files
matmenclaude
andcommitted
metadata for every format
All encoders take the metadata `encode()` takes for PNGs (`Metadata`, `PNGMetadata` stays as a deprecated alias), each format stores the fields it has a place for: - JPEG: Exif (APP1) and a comment marker - WebP: Exif (the extended format's EXIF chunk), still and animated - GIF: comment extensions - TGA: the TGA 2.0 extension area - APNG: text chunks, like PNG The defaults are the same everywhere: the creation time and ImageScript with its version as the software (`software: ''` leaves it out). PNG text that isn't Latin-1 goes into iTXt chunks as UTF-8 instead of tEXt, and the text chunks come before the image data. An invalid creation time throws instead of writing "Invalid Date". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J
1 parent f37a538 commit a565556

13 files changed

Lines changed: 440 additions & 90 deletions

File tree

‎CHANGELOG.md‎

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

3+
## 3.2.0
4+
5+
### New
6+
7+
- **Metadata in every format**: `encodeJPEG()`, `encodeWebP()`, `encodeTGA()` and, for animations, `encodeGIF()`,
8+
`encodeAPNG()` and `encodeWebP()` take the same metadata as `encode()` (now called `Metadata`). Each format stores
9+
the fields it has a place for: Exif and a comment for JPEG, Exif for WebP, comments for GIF, the extension area for
10+
TGA, text chunks for PNG and APNG. Like PNGs, all files name ImageScript and its version as the software and
11+
include the creation time by default; `{software: ''}` leaves the software out.
12+
13+
### Deprecated
14+
15+
- **`PNGMetadata`**: renamed to `Metadata`, the old name stays an alias until 4.0.
16+
17+
### Fixes
18+
19+
- PNG text metadata: text that isn't Latin-1 (like `©` is, but `Ⓒ` or emoji aren't) is written into `iTXt` chunks as
20+
UTF-8 instead of `tEXt`, where readers showed it garbled; text chunks come before the image data, where all readers
21+
look for them.
22+
- An invalid `creationTime` throws a `RangeError` instead of writing "Invalid Date".
23+
324
## 3.1.0
425

526
### New

‎src/animation.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { BinaryData, view } from './util/mem.ts';
55
import { Image, ResizeMode } from './image.ts';
66
import { Frame } from './frame.ts';
77
import { ImageType } from './image-type.ts';
8+
import { Metadata, resolveMetadata, pngText, exifData, gifComments } from './metadata.ts';
89

910
// loop counts: ImageScript (like GIF) counts repetitions (-1 forever, 0 play once), APNG and WebP count plays (0 forever)
1011
const toPlays = (loopCount: number): number => loopCount < 0 ? 0 : loopCount + 1;
@@ -118,8 +119,9 @@ export class Animation extends Array<Frame> {
118119
/**
119120
* Encodes the animation as GIF
120121
* @param quality 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above)
122+
* @param metadata Metadata to include, see {@link Metadata}
121123
*/
122-
async encodeGIF(quality = 95): Promise<Uint8Array> {
124+
async encodeGIF(quality = 95, metadata: Metadata = {}): Promise<Uint8Array> {
123125
this.__validate__();
124126
const frames: gifCodec.GIFFrame[] = [];
125127

@@ -135,34 +137,37 @@ export class Animation extends Array<Frame> {
135137
pixels: new Uint8Array(frame.bitmap.buffer, frame.bitmap.byteOffset, frame.bitmap.byteLength)
136138
});
137139

138-
return gifCodec.encode({ width: this.width, height: this.height, loopCount: this.loopCount, frames }, quality);
140+
return gifCodec.encode({ width: this.width, height: this.height, loopCount: this.loopCount, frames }, quality, gifComments(resolveMetadata(metadata)));
139141
}
140142

141143
/**
142144
* Encodes the animation as APNG (animated PNG, lossless and with full alpha). Viewers without APNG support show
143145
* the first frame
144146
* @param compression The zlib compression level (0-9), higher is smaller but slower
147+
* @param metadata Metadata to include, see {@link Metadata}
145148
*/
146-
async encodeAPNG(compression = 1): Promise<Uint8Array> {
149+
async encodeAPNG(compression = 1, metadata: Metadata = {}): Promise<Uint8Array> {
147150
this.__validate__();
148151
if (!this.length) throw new Error('An animation needs at least one frame');
149152

150-
return png.encodeAnimation(this.__apngFrames__(), this.width, this.height, toPlays(this.loopCount), compression);
153+
return png.encodeAnimation(this.__apngFrames__(), this.width, this.height, toPlays(this.loopCount), compression, pngText(resolveMetadata(metadata)));
151154
}
152155

153156
/**
154157
* Encodes the animation as lossless animated WebP
158+
* @param metadata Metadata to include, see {@link Metadata}
155159
*/
156-
async encodeWebP(): Promise<Uint8Array> {
160+
async encodeWebP(metadata: Metadata = {}): Promise<Uint8Array> {
157161
this.__validate__();
158162
if (!this.length) throw new Error('An animation needs at least one frame');
159163
const width = this.width, height = this.height;
164+
const exif = exifData(resolveMetadata(metadata));
160165

161166
// WebP knows no "previous" disposal and stores offsets halved: such animations are stored as full frames
162167
const fullFrames = this.some(f => f.disposalMode === 'previous' || (f.disposalMode === 'background' && (f.xOffset & 1 || f.yOffset & 1)));
163168
if (fullFrames) {
164169
const canvases = png.compositeAnimation({ width, height, frames: this.__apngFrames__() });
165-
return webp.encodeAnimation(canvases.map(c => ({ x: 0, y: 0, width, height, duration: c.duration, dispose: true, pixels: c.pixels })), width, height, toPlays(this.loopCount));
170+
return webp.encodeAnimation(canvases.map(c => ({ x: 0, y: 0, width, height, duration: c.duration, dispose: true, pixels: c.pixels })), width, height, toPlays(this.loopCount), exif);
166171
}
167172

168173
const frames = Array.from(this, frame => {
@@ -178,7 +183,7 @@ export class Animation extends Array<Frame> {
178183
}
179184
return { x, y, width: w, height: h, duration: frame.duration, dispose: frame.disposalMode === 'background', pixels };
180185
});
181-
return webp.encodeAnimation(frames, width, height, toPlays(this.loopCount));
186+
return webp.encodeAnimation(frames, width, height, toPlays(this.loopCount), exif);
182187
}
183188

184189
/** @internal frames in the APNG codec's terms (alpha blended, disposal 0 none, 1 background, 2 previous) */

‎src/codecs/exif.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { concat } from '../util/mem.ts';
2+
import { encode as utf8 } from '../util/utf8.ts';
3+
14
/**
25
* Reads the orientation tag (0x0112) from the first IFD of Exif data
36
* @param data a TIFF header and IFDs, optionally preceded by "Exif\0\0" (as in JPEG's APP1)
@@ -36,3 +39,84 @@ export function xmpOrientation(xmp: string): number {
3639
const match = /tiff:Orientation\s*(?:=\s*["']|>\s*)([1-8])\b/.exec(xmp);
3740
return match ? +match[1] : 1;
3841
}
42+
43+
export interface ExifFields {
44+
description?: string;
45+
software?: string;
46+
/** written as UTC (DateTime and DateTimeOriginal, with their offsets) */
47+
dateTime?: Date;
48+
artist?: string;
49+
copyright?: string;
50+
}
51+
52+
const ASCII = 2, LONG = 4, UNDEFINED = 7;
53+
54+
interface Entry {
55+
tag: number;
56+
type: number;
57+
count: number;
58+
data: Uint8Array;
59+
}
60+
61+
// ASCII values are NUL terminated; other text than ASCII is stored as UTF-8, which readers commonly accept
62+
const ascii = (tag: number, text: string): Entry => {
63+
const data = concat([utf8(text), [0]]);
64+
return { tag, type: ASCII, count: data.length, data };
65+
};
66+
67+
const pad = (n: number): string => String(n).padStart(2, '0');
68+
69+
/**
70+
* Writes Exif data: a big endian TIFF header, IFD0 and (with a date) an Exif IFD
71+
* @return the TIFF data, without the "Exif\0\0" prefix that JPEG's APP1 adds
72+
*/
73+
export function encode(fields: ExifFields): Uint8Array {
74+
const ifd0: Entry[] = [];
75+
const exifIFD: Entry[] = [];
76+
if (fields.description) ifd0.push(ascii(0x010e, fields.description));
77+
if (fields.software) ifd0.push(ascii(0x0131, fields.software));
78+
if (fields.dateTime) {
79+
const d = fields.dateTime;
80+
const time = `${d.getUTCFullYear()}:${pad(d.getUTCMonth() + 1)}:${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
81+
ifd0.push(ascii(0x0132, time));
82+
exifIFD.push({ tag: 0x9000, type: UNDEFINED, count: 4, data: Uint8Array.of(0x30, 0x32, 0x33, 0x32) }); // ExifVersion 0232
83+
exifIFD.push(ascii(0x9003, time)); // DateTimeOriginal
84+
exifIFD.push(ascii(0x9010, '+00:00')); // OffsetTime
85+
exifIFD.push(ascii(0x9011, '+00:00')); // OffsetTimeOriginal
86+
}
87+
if (fields.artist) ifd0.push(ascii(0x013b, fields.artist));
88+
if (fields.copyright) ifd0.push(ascii(0x8298, fields.copyright));
89+
if (exifIFD.length) ifd0.push({ tag: 0x8769, type: LONG, count: 1, data: new Uint8Array(4) }); // pointer, set below
90+
91+
const size = (entries: Entry[]): number => 2 + entries.length * 12 + 4 + entries.reduce((n, e) => n + (e.data.length > 4 ? e.data.length + (e.data.length & 1) : 0), 0);
92+
const ifd0Offset = 8;
93+
const exifOffset = ifd0Offset + size(ifd0);
94+
const out = new Uint8Array(exifOffset + (exifIFD.length ? size(exifIFD) : 0));
95+
const view = new DataView(out.buffer);
96+
out.set([0x4d, 0x4d, 0x00, 0x2a]);
97+
view.setUint32(4, ifd0Offset);
98+
99+
const pointer = ifd0.find(e => e.tag === 0x8769);
100+
if (pointer) new DataView(pointer.data.buffer).setUint32(0, exifOffset);
101+
102+
const write = (entries: Entry[], offset: number): void => {
103+
view.setUint16(offset, entries.length);
104+
// values that don't fit into an entry follow the IFD (and its next IFD offset, 0)
105+
let data = offset + 2 + entries.length * 12 + 4;
106+
entries.forEach((e, i) => {
107+
const at = offset + 2 + i * 12;
108+
view.setUint16(at, e.tag);
109+
view.setUint16(at + 2, e.type);
110+
view.setUint32(at + 4, e.count);
111+
if (e.data.length <= 4) out.set(e.data, at + 8);
112+
else {
113+
view.setUint32(at + 8, data);
114+
out.set(e.data, data);
115+
data += e.data.length + (e.data.length & 1);
116+
}
117+
});
118+
};
119+
write(ifd0, ifd0Offset);
120+
if (exifIFD.length) write(exifIFD, exifOffset);
121+
return out;
122+
}

‎src/codecs/gif.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ interface GraphicControl {
4040
* @param {number} [quality=95] 1-100, scales the palette size (1 = 8 colors, 50 = 55, 90 and above = 256)
4141
* @return {Uint8Array}
4242
*/
43-
export function encode({ width, height, loopCount = -1, frames }: Omit<GIFData, 'loopCount'> & { loopCount?: number }, quality = 95): Uint8Array {
43+
/**
44+
* @param comments written as comment extensions
45+
*/
46+
export function encode({ width, height, loopCount = -1, frames }: Omit<GIFData, 'loopCount'> & { loopCount?: number }, quality = 95, comments: Uint8Array[] = []): Uint8Array {
4447
const out = new ByteWriter();
4548

4649
out.bytes(0x47, 0x49, 0x46, 0x38, 0x39, 0x61); // GIF89a
@@ -56,6 +59,11 @@ export function encode({ width, height, loopCount = -1, frames }: Omit<GIFData,
5659
out.bytes(0x00);
5760
}
5861

62+
for (const comment of comments) {
63+
out.bytes(0x21, 0xfe);
64+
writeSubBlocks(out, comment);
65+
}
66+
5967
for (const frame of frames) {
6068
const { palette, indices, transparentIndex } = quantize(frame.pixels, frame.width, { quality });
6169
const entries = palette.length / 3;

‎src/codecs/jpeg.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import { orientation as exifOrientation } from './exif.ts';
9+
import { concat } from '../util/mem.ts';
910

1011
// natural order index of the n-th zigzag coefficient
1112
const ZIGZAG = Int32Array.of(
@@ -843,7 +844,14 @@ const AAN_SCALE = [1, 1.387039845, 1.306562965, 1.175875602, 1, 0.785694958, 0.5
843844
* @param {number} [quality=90] 1-100
844845
* @return {Uint8Array}
845846
*/
846-
export function encode(pixels: Uint8Array, width: number, height: number, quality = 90): Uint8Array {
847+
export interface EncodeMetadata {
848+
/** Exif data (a TIFF header and IFDs), written as APP1 */
849+
exif?: Uint8Array;
850+
/** written as comment (COM) */
851+
comment?: Uint8Array;
852+
}
853+
854+
export function encode(pixels: Uint8Array, width: number, height: number, quality = 90, { exif, comment }: EncodeMetadata = {}): Uint8Array {
847855
if (!width || !height || width > 0xffff || height > 0xffff) throw new RangeError('JPEG: invalid dimensions');
848856

849857
quality = Math.min(100, Math.max(1, Math.round(quality)));
@@ -950,6 +958,14 @@ export function encode(pixels: Uint8Array, width: number, height: number, qualit
950958
writer.bytes(0xff, 0xd8);
951959
// JFIF APP0
952960
writer.bytes(0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00);
961+
// segments hold up to 65533 bytes
962+
const segment = (marker: number, body: ArrayLike<number>): void => {
963+
if (body.length > 65533) throw new RangeError('JPEG: metadata too large');
964+
writer.bytes(0xff, marker, body.length + 2 >> 8, body.length + 2 & 255);
965+
for (let i = 0; i < body.length; i++) writer.bytes(body[i]);
966+
};
967+
if (exif) segment(0xe1, concat([[0x45, 0x78, 0x69, 0x66, 0, 0], exif]));
968+
if (comment) segment(0xfe, comment);
953969

954970
// DQT
955971
writer.bytes(0xff, 0xdb, 0x00, 0x84);

‎src/codecs/png.ts‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const CHANNELS_TO_COLOR_TYPE: Record<number, number> = { 1: 0, 2: 4, 3: 2, 4: 6
2323
const VALID_DEPTHS: Record<number, number[]> = { 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16] };
2424

2525
export interface EncodeOptions {
26-
/** tEXt chunks, empty values are skipped */
26+
/** text chunks, empty values are skipped */
2727
text?: Record<string, string | undefined>;
2828
width: number;
2929
height: number;
@@ -83,16 +83,25 @@ export async function encode(data: Uint8Array, { text, width, height, channels,
8383
const chunks = [chunk(IHDR, header)];
8484
if (palette) chunks.push(chunk(PLTE, palette));
8585
if (transparency) chunks.push(chunk(tRNS, transparency));
86+
chunks.push(...textChunks(text));
8687
chunks.push(chunk(IDAT, await compressAsync(filtered, level)));
8788

89+
chunks.push(chunk(IEND, new Uint8Array(0)));
90+
return concat([HEAD, ...chunks]);
91+
}
92+
93+
// text chunks (before the image data, where all readers find them), empty values are skipped: tEXt holds Latin-1,
94+
// other text goes into an (uncompressed) iTXt as UTF-8
95+
function textChunks(text: EncodeOptions['text']): Uint8Array[] {
96+
const chunks: Uint8Array[] = [];
97+
const latin1 = (s: string): number[] => Array.from(s, c => c.charCodeAt(0));
8898
for (const key in text) {
8999
const value = text[key];
90100
if (!value) continue;
91-
chunks.push(chunk(0x74455874, concat([utf8(key), [0], utf8(value)])));
101+
if (/^[\x00-\xff]*$/.test(value)) chunks.push(chunk(tEXt, concat([latin1(key), [0], latin1(value)])));
102+
else chunks.push(chunk(iTXt, concat([latin1(key), [0, 0, 0, 0, 0], utf8(value)])));
92103
}
93-
94-
chunks.push(chunk(IEND, new Uint8Array(0)));
95-
return concat([HEAD, ...chunks]);
104+
return chunks;
96105
}
97106

98107
/**
@@ -272,8 +281,9 @@ export interface APNGEncodeFrame {
272281
* Encodes frames into an APNG. Frames are alpha blended onto the canvas ("over"); the first frame is also the
273282
* default image that viewers without APNG support show
274283
* @param plays How often to play the animation, 0 = forever
284+
* @param text tEXt chunks, empty values are skipped
275285
*/
276-
export async function encodeAnimation(frames: APNGEncodeFrame[], width: number, height: number, plays: number, level: number): Promise<Uint8Array> {
286+
export async function encodeAnimation(frames: APNGEncodeFrame[], width: number, height: number, plays: number, level: number, text?: EncodeOptions['text']): Promise<Uint8Array> {
277287
if (!frames.length) throw new Error('PNG: an animation needs at least one frame');
278288

279289
// the default image has to cover the whole canvas: pad the first frame if needed
@@ -310,6 +320,7 @@ export async function encodeAnimation(frames: APNGEncodeFrame[], width: number,
310320
const chunks = [chunk(IHDR, header), chunk(acTL, actl)];
311321
if (palette) chunks.push(chunk(PLTE, palette));
312322
if (transparency) chunks.push(chunk(tRNS, transparency));
323+
chunks.push(...textChunks(text));
313324

314325
let sequence = 0;
315326
for (let i = 0; i < frames.length; i++) {

‎src/codecs/tga.ts‎

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { encode as utf8 } from '../util/utf8.ts';
2+
13
// Truevision TGA: color mapped, truecolor and greyscale images, uncompressed or run length encoded
24

35
const SIGNATURE = 'TRUEVISION-XFILE.\0';
@@ -164,7 +166,47 @@ export function decode(d: Uint8Array): { width: number, height: number, pixels:
164166
/**
165167
* Encodes RGBA pixels into a run length encoded TGA (32 bit with alpha, 24 bit if opaque), top left origin
166168
*/
167-
export function encode(rgba: Uint8Array, width: number, height: number): Uint8Array {
169+
export interface Extension {
170+
author?: string;
171+
/** up to four lines of 80 bytes */
172+
comment?: string;
173+
time?: Date;
174+
software?: string;
175+
}
176+
177+
// UTF-8, split into pieces of whole characters of at most `max` bytes
178+
function pieces(value: string, max: number): Uint8Array[] {
179+
const bytes = utf8(value);
180+
const out: Uint8Array[] = [];
181+
for (let start = 0; start < bytes.length;) {
182+
let end = Math.min(bytes.length, start + max);
183+
while (end < bytes.length && end > start && (bytes[end] & 0xc0) === 0x80) end--;
184+
out.push(bytes.subarray(start, end));
185+
start = end;
186+
}
187+
return out;
188+
}
189+
190+
// the TGA 2.0 extension area (495 bytes), text fields are NUL terminated
191+
function extensionArea({ author, comment, time, software }: Extension, alpha: boolean): Uint8Array {
192+
const area = new Uint8Array(495);
193+
const view = new DataView(area.buffer);
194+
view.setUint16(0, 495, true);
195+
if (author) area.set(pieces(author, 40)[0], 2);
196+
if (comment) {
197+
// four lines of 80 bytes, longer lines wrap
198+
const lines = comment.split(/\r?\n/).flatMap(line => line ? pieces(line, 80) : [new Uint8Array(0)]).slice(0, 4);
199+
lines.forEach((line, i) => area.set(line, 43 + i * 81));
200+
}
201+
if (time) [time.getUTCMonth() + 1, time.getUTCDate(), time.getUTCFullYear(), time.getUTCHours(), time.getUTCMinutes(), time.getUTCSeconds()]
202+
.forEach((v, i) => view.setUint16(367 + i * 2, v, true));
203+
if (software) area.set(pieces(software, 40)[0], 426);
204+
area[469] = 0x20; // no software version letter
205+
area[494] = alpha ? 3 : 0; // attributes type: useful alpha or none
206+
return area;
207+
}
208+
209+
export function encode(rgba: Uint8Array, width: number, height: number, extension?: Extension): Uint8Array {
168210
if (width < 1 || height < 1 || width > 65535 || height > 65535) throw new RangeError('TGA: dimensions must be 1-65535');
169211

170212
let opaque = true;
@@ -175,7 +217,7 @@ export function encode(rgba: Uint8Array, width: number, height: number): Uint8Ar
175217
const pixels = new Uint32Array(rgba.buffer, rgba.byteOffset, count);
176218

177219
// worst case: a header byte per 128 raw pixels
178-
const out = new Uint8Array(18 + count * bpp + Math.ceil(count / 128) + 26);
220+
const out = new Uint8Array(18 + count * bpp + Math.ceil(count / 128) + 495 + 26);
179221
out[2] = 10; // RLE truecolor
180222
out[12] = width & 255; out[13] = width >> 8;
181223
out[14] = height & 255; out[15] = height >> 8;
@@ -209,7 +251,14 @@ export function encode(rgba: Uint8Array, width: number, height: number): Uint8Ar
209251
i += raw;
210252
}
211253

212-
// TGA 2.0 footer without extension and developer areas, it makes the file recognizable
254+
// TGA 2.0 footer (without developer area), it makes the file recognizable
255+
let extensionOffset = 0;
256+
if (extension && (extension.author || extension.comment || extension.time || extension.software)) {
257+
extensionOffset = o;
258+
out.set(extensionArea(extension, bpp === 4), o);
259+
o += 495;
260+
}
261+
out[o] = extensionOffset & 255; out[o + 1] = extensionOffset >> 8 & 255; out[o + 2] = extensionOffset >> 16 & 255; out[o + 3] = extensionOffset >>> 24;
213262
o += 8;
214263
for (let i = 0; i < SIGNATURE.length; i++) out[o++] = SIGNATURE.charCodeAt(i);
215264
return out.slice(0, o);

0 commit comments

Comments
 (0)