diff --git a/src/animation.ts b/src/animation.ts
index 0a88a08..3759152 100644
--- a/src/animation.ts
+++ b/src/animation.ts
@@ -5,6 +5,7 @@ import { BinaryData, view } from './util/mem.ts';
import { Image, ResizeMode } from './image.ts';
import { Frame } from './frame.ts';
import { ImageType } from './image-type.ts';
+import { Metadata, resolveMetadata, pngText, exifData, gifComments } from './metadata.ts';
// loop counts: ImageScript (like GIF) counts repetitions (-1 forever, 0 play once), APNG and WebP count plays (0 forever)
const toPlays = (loopCount: number): number => loopCount < 0 ? 0 : loopCount + 1;
@@ -118,8 +119,9 @@ export class Animation extends Array {
/**
* Encodes the animation as GIF
* @param quality 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above)
+ * @param metadata Metadata to include, see {@link Metadata}
*/
- async encodeGIF(quality = 95): Promise {
+ async encodeGIF(quality = 95, metadata: Metadata = {}): Promise {
this.__validate__();
const frames: gifCodec.GIFFrame[] = [];
@@ -135,34 +137,37 @@ export class Animation extends Array {
pixels: new Uint8Array(frame.bitmap.buffer, frame.bitmap.byteOffset, frame.bitmap.byteLength)
});
- return gifCodec.encode({ width: this.width, height: this.height, loopCount: this.loopCount, frames }, quality);
+ return gifCodec.encode({ width: this.width, height: this.height, loopCount: this.loopCount, frames }, quality, gifComments(resolveMetadata(metadata)));
}
/**
* Encodes the animation as APNG (animated PNG, lossless and with full alpha). Viewers without APNG support show
* the first frame
* @param compression The zlib compression level (0-9), higher is smaller but slower
+ * @param metadata Metadata to include, see {@link Metadata}
*/
- async encodeAPNG(compression = 1): Promise {
+ async encodeAPNG(compression = 1, metadata: Metadata = {}): Promise {
this.__validate__();
if (!this.length) throw new Error('An animation needs at least one frame');
- return png.encodeAnimation(this.__apngFrames__(), this.width, this.height, toPlays(this.loopCount), compression);
+ return png.encodeAnimation(this.__apngFrames__(), this.width, this.height, toPlays(this.loopCount), compression, pngText(resolveMetadata(metadata)));
}
/**
* Encodes the animation as lossless animated WebP
+ * @param metadata Metadata to include, see {@link Metadata}
*/
- async encodeWebP(): Promise {
+ async encodeWebP(metadata: Metadata = {}): Promise {
this.__validate__();
if (!this.length) throw new Error('An animation needs at least one frame');
const width = this.width, height = this.height;
+ const exif = exifData(resolveMetadata(metadata));
// WebP knows no "previous" disposal and stores offsets halved: such animations are stored as full frames
const fullFrames = this.some(f => f.disposalMode === 'previous' || (f.disposalMode === 'background' && (f.xOffset & 1 || f.yOffset & 1)));
if (fullFrames) {
const canvases = png.compositeAnimation({ width, height, frames: this.__apngFrames__() });
- 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));
+ 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);
}
const frames = Array.from(this, frame => {
@@ -178,7 +183,7 @@ export class Animation extends Array {
}
return { x, y, width: w, height: h, duration: frame.duration, dispose: frame.disposalMode === 'background', pixels };
});
- return webp.encodeAnimation(frames, width, height, toPlays(this.loopCount));
+ return webp.encodeAnimation(frames, width, height, toPlays(this.loopCount), exif);
}
/** @internal frames in the APNG codec's terms (alpha blended, disposal 0 none, 1 background, 2 previous) */
diff --git a/src/codecs/exif.ts b/src/codecs/exif.ts
index c8b6666..b4be5fc 100644
--- a/src/codecs/exif.ts
+++ b/src/codecs/exif.ts
@@ -1,3 +1,6 @@
+import { concat } from '../util/mem.ts';
+import { encode as utf8 } from '../util/utf8.ts';
+
/**
* Reads the orientation tag (0x0112) from the first IFD of Exif data
* @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 {
const match = /tiff:Orientation\s*(?:=\s*["']|>\s*)([1-8])\b/.exec(xmp);
return match ? +match[1] : 1;
}
+
+export interface ExifFields {
+ description?: string;
+ software?: string;
+ /** written as UTC (DateTime and DateTimeOriginal, with their offsets) */
+ dateTime?: Date;
+ artist?: string;
+ copyright?: string;
+}
+
+const ASCII = 2, LONG = 4, UNDEFINED = 7;
+
+interface Entry {
+ tag: number;
+ type: number;
+ count: number;
+ data: Uint8Array;
+}
+
+// ASCII values are NUL terminated; other text than ASCII is stored as UTF-8, which readers commonly accept
+const ascii = (tag: number, text: string): Entry => {
+ const data = concat([utf8(text), [0]]);
+ return { tag, type: ASCII, count: data.length, data };
+};
+
+const pad = (n: number): string => String(n).padStart(2, '0');
+
+/**
+ * Writes Exif data: a big endian TIFF header, IFD0 and (with a date) an Exif IFD
+ * @return the TIFF data, without the "Exif\0\0" prefix that JPEG's APP1 adds
+ */
+export function encode(fields: ExifFields): Uint8Array {
+ const ifd0: Entry[] = [];
+ const exifIFD: Entry[] = [];
+ if (fields.description) ifd0.push(ascii(0x010e, fields.description));
+ if (fields.software) ifd0.push(ascii(0x0131, fields.software));
+ if (fields.dateTime) {
+ const d = fields.dateTime;
+ const time = `${d.getUTCFullYear()}:${pad(d.getUTCMonth() + 1)}:${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
+ ifd0.push(ascii(0x0132, time));
+ exifIFD.push({ tag: 0x9000, type: UNDEFINED, count: 4, data: Uint8Array.of(0x30, 0x32, 0x33, 0x32) }); // ExifVersion 0232
+ exifIFD.push(ascii(0x9003, time)); // DateTimeOriginal
+ exifIFD.push(ascii(0x9010, '+00:00')); // OffsetTime
+ exifIFD.push(ascii(0x9011, '+00:00')); // OffsetTimeOriginal
+ }
+ if (fields.artist) ifd0.push(ascii(0x013b, fields.artist));
+ if (fields.copyright) ifd0.push(ascii(0x8298, fields.copyright));
+ if (exifIFD.length) ifd0.push({ tag: 0x8769, type: LONG, count: 1, data: new Uint8Array(4) }); // pointer, set below
+
+ 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);
+ const ifd0Offset = 8;
+ const exifOffset = ifd0Offset + size(ifd0);
+ const out = new Uint8Array(exifOffset + (exifIFD.length ? size(exifIFD) : 0));
+ const view = new DataView(out.buffer);
+ out.set([0x4d, 0x4d, 0x00, 0x2a]);
+ view.setUint32(4, ifd0Offset);
+
+ const pointer = ifd0.find(e => e.tag === 0x8769);
+ if (pointer) new DataView(pointer.data.buffer).setUint32(0, exifOffset);
+
+ const write = (entries: Entry[], offset: number): void => {
+ view.setUint16(offset, entries.length);
+ // values that don't fit into an entry follow the IFD (and its next IFD offset, 0)
+ let data = offset + 2 + entries.length * 12 + 4;
+ entries.forEach((e, i) => {
+ const at = offset + 2 + i * 12;
+ view.setUint16(at, e.tag);
+ view.setUint16(at + 2, e.type);
+ view.setUint32(at + 4, e.count);
+ if (e.data.length <= 4) out.set(e.data, at + 8);
+ else {
+ view.setUint32(at + 8, data);
+ out.set(e.data, data);
+ data += e.data.length + (e.data.length & 1);
+ }
+ });
+ };
+ write(ifd0, ifd0Offset);
+ if (exifIFD.length) write(exifIFD, exifOffset);
+ return out;
+}
diff --git a/src/codecs/gif.ts b/src/codecs/gif.ts
index 39bf3bd..dbd3e6d 100644
--- a/src/codecs/gif.ts
+++ b/src/codecs/gif.ts
@@ -40,7 +40,10 @@ interface GraphicControl {
* @param {number} [quality=95] 1-100, scales the palette size (1 = 8 colors, 50 = 55, 90 and above = 256)
* @return {Uint8Array}
*/
-export function encode({ width, height, loopCount = -1, frames }: Omit & { loopCount?: number }, quality = 95): Uint8Array {
+/**
+ * @param comments written as comment extensions
+ */
+export function encode({ width, height, loopCount = -1, frames }: Omit & { loopCount?: number }, quality = 95, comments: Uint8Array[] = []): Uint8Array {
const out = new ByteWriter();
out.bytes(0x47, 0x49, 0x46, 0x38, 0x39, 0x61); // GIF89a
@@ -56,6 +59,11 @@ export function encode({ width, height, loopCount = -1, frames }: Omit 0xffff || height > 0xffff) throw new RangeError('JPEG: invalid dimensions');
quality = Math.min(100, Math.max(1, Math.round(quality)));
@@ -950,6 +958,14 @@ export function encode(pixels: Uint8Array, width: number, height: number, qualit
writer.bytes(0xff, 0xd8);
// JFIF APP0
writer.bytes(0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00);
+ // segments hold up to 65533 bytes
+ const segment = (marker: number, body: ArrayLike): void => {
+ if (body.length > 65533) throw new RangeError('JPEG: metadata too large');
+ writer.bytes(0xff, marker, body.length + 2 >> 8, body.length + 2 & 255);
+ for (let i = 0; i < body.length; i++) writer.bytes(body[i]);
+ };
+ if (exif) segment(0xe1, concat([[0x45, 0x78, 0x69, 0x66, 0, 0], exif]));
+ if (comment) segment(0xfe, comment);
// DQT
writer.bytes(0xff, 0xdb, 0x00, 0x84);
diff --git a/src/codecs/png.ts b/src/codecs/png.ts
index f140971..22fcf1d 100644
--- a/src/codecs/png.ts
+++ b/src/codecs/png.ts
@@ -23,7 +23,7 @@ const CHANNELS_TO_COLOR_TYPE: Record = { 1: 0, 2: 4, 3: 2, 4: 6
const VALID_DEPTHS: Record = { 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16] };
export interface EncodeOptions {
- /** tEXt chunks, empty values are skipped */
+ /** text chunks, empty values are skipped */
text?: Record;
width: number;
height: number;
@@ -83,16 +83,25 @@ export async function encode(data: Uint8Array, { text, width, height, channels,
const chunks = [chunk(IHDR, header)];
if (palette) chunks.push(chunk(PLTE, palette));
if (transparency) chunks.push(chunk(tRNS, transparency));
+ chunks.push(...textChunks(text));
chunks.push(chunk(IDAT, await compressAsync(filtered, level)));
+ chunks.push(chunk(IEND, new Uint8Array(0)));
+ return concat([HEAD, ...chunks]);
+}
+
+// text chunks (before the image data, where all readers find them), empty values are skipped: tEXt holds Latin-1,
+// other text goes into an (uncompressed) iTXt as UTF-8
+function textChunks(text: EncodeOptions['text']): Uint8Array[] {
+ const chunks: Uint8Array[] = [];
+ const latin1 = (s: string): number[] => Array.from(s, c => c.charCodeAt(0));
for (const key in text) {
const value = text[key];
if (!value) continue;
- chunks.push(chunk(0x74455874, concat([utf8(key), [0], utf8(value)])));
+ if (/^[\x00-\xff]*$/.test(value)) chunks.push(chunk(tEXt, concat([latin1(key), [0], latin1(value)])));
+ else chunks.push(chunk(iTXt, concat([latin1(key), [0, 0, 0, 0, 0], utf8(value)])));
}
-
- chunks.push(chunk(IEND, new Uint8Array(0)));
- return concat([HEAD, ...chunks]);
+ return chunks;
}
/**
@@ -272,8 +281,9 @@ export interface APNGEncodeFrame {
* Encodes frames into an APNG. Frames are alpha blended onto the canvas ("over"); the first frame is also the
* default image that viewers without APNG support show
* @param plays How often to play the animation, 0 = forever
+ * @param text tEXt chunks, empty values are skipped
*/
-export async function encodeAnimation(frames: APNGEncodeFrame[], width: number, height: number, plays: number, level: number): Promise {
+export async function encodeAnimation(frames: APNGEncodeFrame[], width: number, height: number, plays: number, level: number, text?: EncodeOptions['text']): Promise {
if (!frames.length) throw new Error('PNG: an animation needs at least one frame');
// 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,
const chunks = [chunk(IHDR, header), chunk(acTL, actl)];
if (palette) chunks.push(chunk(PLTE, palette));
if (transparency) chunks.push(chunk(tRNS, transparency));
+ chunks.push(...textChunks(text));
let sequence = 0;
for (let i = 0; i < frames.length; i++) {
diff --git a/src/codecs/tga.ts b/src/codecs/tga.ts
index d958c17..c416d0c 100644
--- a/src/codecs/tga.ts
+++ b/src/codecs/tga.ts
@@ -1,3 +1,5 @@
+import { encode as utf8 } from '../util/utf8.ts';
+
// Truevision TGA: color mapped, truecolor and greyscale images, uncompressed or run length encoded
const SIGNATURE = 'TRUEVISION-XFILE.\0';
@@ -164,7 +166,47 @@ export function decode(d: Uint8Array): { width: number, height: number, pixels:
/**
* 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 {
+export interface Extension {
+ author?: string;
+ /** up to four lines of 80 bytes */
+ comment?: string;
+ time?: Date;
+ software?: string;
+}
+
+// UTF-8, split into pieces of whole characters of at most `max` bytes
+function pieces(value: string, max: number): Uint8Array[] {
+ const bytes = utf8(value);
+ const out: Uint8Array[] = [];
+ for (let start = 0; start < bytes.length;) {
+ let end = Math.min(bytes.length, start + max);
+ while (end < bytes.length && end > start && (bytes[end] & 0xc0) === 0x80) end--;
+ out.push(bytes.subarray(start, end));
+ start = end;
+ }
+ return out;
+}
+
+// the TGA 2.0 extension area (495 bytes), text fields are NUL terminated
+function extensionArea({ author, comment, time, software }: Extension, alpha: boolean): Uint8Array {
+ const area = new Uint8Array(495);
+ const view = new DataView(area.buffer);
+ view.setUint16(0, 495, true);
+ if (author) area.set(pieces(author, 40)[0], 2);
+ if (comment) {
+ // four lines of 80 bytes, longer lines wrap
+ const lines = comment.split(/\r?\n/).flatMap(line => line ? pieces(line, 80) : [new Uint8Array(0)]).slice(0, 4);
+ lines.forEach((line, i) => area.set(line, 43 + i * 81));
+ }
+ if (time) [time.getUTCMonth() + 1, time.getUTCDate(), time.getUTCFullYear(), time.getUTCHours(), time.getUTCMinutes(), time.getUTCSeconds()]
+ .forEach((v, i) => view.setUint16(367 + i * 2, v, true));
+ if (software) area.set(pieces(software, 40)[0], 426);
+ area[469] = 0x20; // no software version letter
+ area[494] = alpha ? 3 : 0; // attributes type: useful alpha or none
+ return area;
+}
+
+export function encode(rgba: Uint8Array, width: number, height: number, extension?: Extension): Uint8Array {
if (width < 1 || height < 1 || width > 65535 || height > 65535) throw new RangeError('TGA: dimensions must be 1-65535');
let opaque = true;
@@ -174,8 +216,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 of a row
- const out = new Uint8Array(18 + count * bpp + height * Math.ceil(width / 128) + 26);
+ // worst case: a header byte per 128 raw pixels of a row, the extension area and the footer
+ const out = new Uint8Array(18 + count * bpp + height * Math.ceil(width / 128) + 495 + 26);
out[2] = 10; // RLE truecolor
out[12] = width & 255; out[13] = width >> 8;
out[14] = height & 255; out[15] = height >> 8;
@@ -211,7 +253,14 @@ export function encode(rgba: Uint8Array, width: number, height: number): Uint8Ar
i += raw;
}
- // TGA 2.0 footer without extension and developer areas, it makes the file recognizable
+ // TGA 2.0 footer (without developer area), it makes the file recognizable
+ let extensionOffset = 0;
+ if (extension && (extension.author || extension.comment || extension.time || extension.software)) {
+ extensionOffset = o;
+ out.set(extensionArea(extension, bpp === 4), o);
+ o += 495;
+ }
+ out[o] = extensionOffset & 255; out[o + 1] = extensionOffset >> 8 & 255; out[o + 2] = extensionOffset >> 16 & 255; out[o + 3] = extensionOffset >>> 24;
o += 8;
for (let i = 0; i < SIGNATURE.length; i++) out[o++] = SIGNATURE.charCodeAt(i);
return out.slice(0, o);
diff --git a/src/codecs/webp.ts b/src/codecs/webp.ts
index b5f3825..878d55a 100644
--- a/src/codecs/webp.ts
+++ b/src/codecs/webp.ts
@@ -298,11 +298,24 @@ function toARGB(rgba: Uint8Array, count: number): Uint32Array {
const set24 = (d: Uint8Array, o: number, v: number): void => { d[o] = v & 255; d[o + 1] = v >> 8 & 255; d[o + 2] = v >> 16 & 255; };
+const hasAlpha = (rgba: Uint8Array): boolean => {
+ for (let i = 3; i < rgba.length; i += 4) if (rgba[i] !== 255) return true;
+ return false;
+};
+
/**
* Encodes RGBA pixels into a lossless WebP
+ * @param exif Exif data (a TIFF header and IFDs), needs the extended format (VP8X)
*/
-export function encode(rgba: Uint8Array, width: number, height: number): Uint8Array {
- return riff([riffChunk('VP8L', vp8l.encode(toARGB(rgba, width * height), width, height))]);
+export function encode(rgba: Uint8Array, width: number, height: number, exif?: Uint8Array): Uint8Array {
+ const bitstream = riffChunk('VP8L', vp8l.encode(toARGB(rgba, width * height), width, height));
+ if (!exif) return riff([bitstream]);
+
+ const vp8x = new Uint8Array(10);
+ vp8x[0] = 8 | (hasAlpha(rgba.subarray(0, width * height * 4)) ? 16 : 0); // exif, alpha
+ set24(vp8x, 4, width - 1);
+ set24(vp8x, 7, height - 1);
+ return riff([riffChunk('VP8X', vp8x), bitstream, riffChunk('EXIF', exif)]);
}
export interface WebPEncodeFrame {
@@ -321,17 +334,16 @@ export interface WebPEncodeFrame {
/**
* Encodes frames into a lossless animated WebP. Frame offsets have to be even (the format stores them halved)
* @param plays How often to play the animation, 0 = forever
+ * @param exif Exif data (a TIFF header and IFDs)
*/
-export function encodeAnimation(frames: WebPEncodeFrame[], width: number, height: number, plays: number): Uint8Array {
+export function encodeAnimation(frames: WebPEncodeFrame[], width: number, height: number, plays: number, exif?: Uint8Array): Uint8Array {
if (!frames.length) throw new Error('WebP: an animation needs at least one frame');
if (width > 1 << 24 || height > 1 << 24) throw new RangeError('WebP: canvas too large');
- let alpha = false;
- for (const f of frames)
- for (let i = 3; i < f.pixels.length && !alpha; i += 4) if (f.pixels[i] !== 255) alpha = true;
+ const alpha = frames.some(f => hasAlpha(f.pixels));
const vp8x = new Uint8Array(10);
- vp8x[0] = 2 | (alpha ? 16 : 0); // animation, alpha
+ vp8x[0] = 2 | (alpha ? 16 : 0) | (exif ? 8 : 0); // animation, alpha, exif
set24(vp8x, 4, width - 1);
set24(vp8x, 7, height - 1);
@@ -356,5 +368,6 @@ export function encodeAnimation(frames: WebPEncodeFrame[], width: number, height
chunks.push(riffChunk('ANMF', body));
}
+ if (exif) chunks.push(riffChunk('EXIF', exif));
return riff(chunks);
}
diff --git a/src/gif.ts b/src/gif.ts
index d0cd374..f75b08a 100644
--- a/src/gif.ts
+++ b/src/gif.ts
@@ -2,6 +2,7 @@ import { BinaryData, view } from './util/mem.ts';
import { Frame } from './frame.ts';
import { ImageType } from './image-type.ts';
import { Animation } from './animation.ts';
+import { Metadata } from './metadata.ts';
/**
* Represents a GIF image as an array of frames, an {@link Animation} whose `encode()` and `decode()` work with GIFs.
@@ -26,10 +27,11 @@ export class GIF extends Animation {
/**
* Encodes the GIF
* @param quality 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above)
+ * @param metadata Metadata to include, see {@link Metadata}
* @deprecated Use {@link Animation.encodeGIF}
*/
- async encode(quality = 95): Promise {
- return this.encodeGIF(quality);
+ async encode(quality = 95, metadata: Metadata = {}): Promise {
+ return this.encodeGIF(quality, metadata);
}
/**
diff --git a/src/image.ts b/src/image.ts
index e254785..ab76d9a 100644
--- a/src/image.ts
+++ b/src/image.ts
@@ -9,7 +9,8 @@ import * as svgRenderer from './render/svg.ts';
import { BinaryData, view } from './util/mem.ts';
import { ImageType } from './image-type.ts';
import { TextLayout } from './text-layout.ts';
-import { VERSION } from './version.ts';
+import { Metadata, resolveMetadata, pngText, exifData } from './metadata.ts';
+import { encode as utf8 } from './util/utf8.ts';
import { resample, BOX, TRIANGLE, CUBIC } from './util/resample.ts';
import { gaussianBlur, premultiply, unpremultiply } from './util/blur.ts';
@@ -89,29 +90,11 @@ export interface ColorToAlphaOptions {
opacityThreshold?: number;
}
-/** Text metadata written into PNGs by {@link Image.encode} */
-export interface PNGMetadata {
- /** The image's title */
- title?: string;
- /** The image's author */
- author?: string;
- /** The image's description */
- description?: string;
- /** The image's copyright info */
- copyright?: string;
- /** The image's creation time. Default: now */
- creationTime?: string | number | Date;
- /** The software used to create the image. Default: `'github.com/matmen/ImageScript vX.X.X'`, `''` leaves it out */
- software?: string;
- /** A disclaimer for the image */
- disclaimer?: string;
- /** A warning for the image */
- warning?: string;
- /** The image's source */
- source?: string;
- /** A comment for the image */
- comment?: string;
-}
+/**
+ * Text metadata written into PNGs
+ * @deprecated Use {@link Metadata}, which all encoders take
+ */
+export type PNGMetadata = Metadata;
/**
* An RGBA image; provides the codecs and the manipulation functions. Methods that change the image modify it in place
@@ -1567,79 +1550,60 @@ export class Image {
* Encodes the image into a PNG, with the smallest lossless color type (a palette for up to 256 colors,
* greyscale or RGB, alpha only if needed). Uses the runtime's native zlib when available
* @param compression The compression level (0-9). Default: `1`
- * @param metadata Text metadata to include
+ * @param metadata Metadata to include, see {@link Metadata}
* @return The encoded data
*/
- encode(compression?: number, metadata?: PNGMetadata): Promise;
+ encode(compression?: number, metadata?: Metadata): Promise;
/**
* Encodes the image into a PNG with compression level 1
- * @param metadata Text metadata to include
+ * @param metadata Metadata to include, see {@link Metadata}
* @return The encoded data
*/
- encode(metadata?: PNGMetadata): Promise;
- async encode(compression: number | PNGMetadata = 1, metadata: PNGMetadata = {}): Promise {
+ encode(metadata?: Metadata): Promise;
+ async encode(compression: number | Metadata = 1, metadata: Metadata = {}): Promise {
if (typeof compression === 'object' && compression !== null) {
metadata = compression;
compression = 1;
}
const level = Math.min(9, Math.max(0, ~~compression));
- const {
- title,
- author,
- description,
- copyright,
- creationTime,
- software,
- disclaimer,
- warning,
- source,
- comment
- } = metadata || {};
-
- return png.encodeRGBA(this.__bytes__(), this.width, this.height, level, {
- Title: title,
- Author: author,
- Description: description,
- Copyright: copyright,
- 'Creation Time': new Date(creationTime === undefined ? Date.now() : creationTime).toUTCString(),
- Software: software === undefined ? `github.com/matmen/ImageScript v${VERSION}` : software,
- Disclaimer: disclaimer,
- Warning: warning,
- Source: source,
- Comment: comment
- });
+ return png.encodeRGBA(this.__bytes__(), this.width, this.height, level, pngText(resolveMetadata(metadata)));
}
/**
* Encodes the image into a PNG, an alias of {@link Image.encode}
*/
- encodePNG(compression?: number, metadata?: PNGMetadata): Promise;
- encodePNG(metadata?: PNGMetadata): Promise;
- encodePNG(compression?: number | PNGMetadata, metadata?: PNGMetadata): Promise {
+ encodePNG(compression?: number, metadata?: Metadata): Promise;
+ encodePNG(metadata?: Metadata): Promise;
+ encodePNG(compression?: number | Metadata, metadata?: Metadata): Promise {
return this.encode(compression as number, metadata);
}
/**
* Encodes the image into a JPEG
* @param [quality=90] The JPEG quality to use (1-100), chroma subsampling is disabled above 90
+ * @param metadata Metadata to include, see {@link Metadata}
*/
- async encodeJPEG(quality = 90): Promise {
- return jpeg.encode(this.__bytes__(), this.width, this.height, quality);
+ async encodeJPEG(quality = 90, metadata: Metadata = {}): Promise {
+ const m = resolveMetadata(metadata);
+ return jpeg.encode(this.__bytes__(), this.width, this.height, quality, { exif: exifData(m), comment: m.comment ? utf8(m.comment) : undefined });
}
/**
* Encodes the image as lossless WebP (usually 20-30% smaller than PNG, but slower to encode)
+ * @param metadata Metadata to include, see {@link Metadata}
*/
- async encodeWebP(): Promise {
- return webp.encode(this.__bytes__(), this.width, this.height);
+ async encodeWebP(metadata: Metadata = {}): Promise {
+ return webp.encode(this.__bytes__(), this.width, this.height, exifData(resolveMetadata(metadata)));
}
/**
* Encodes the image as run length encoded TGA (32 bit, or 24 bit if the image is opaque)
+ * @param metadata Metadata to include, see {@link Metadata}
*/
- async encodeTGA(): Promise {
- return tga.encode(this.__bytes__(), this.width, this.height);
+ async encodeTGA(metadata: Metadata = {}): Promise {
+ const { author, comment, creationTime: time, software } = resolveMetadata(metadata);
+ return tga.encode(this.__bytes__(), this.width, this.height, { author, comment, time, software });
}
/**
diff --git a/src/index.ts b/src/index.ts
index 84de7b8..e9bdcf8 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -17,6 +17,7 @@ export type { TextLayoutOptions, WrapStyle, HorizontalAlign, VerticalAlign } fro
export { ImageType } from './image-type.ts';
export type { ImageTypeName } from './image-type.ts';
export type { BinaryData } from './util/mem.ts';
+export type { Metadata } from './metadata.ts';
/**
* Decodes any supported image: GIFs are returned as {@link GIF} (an {@link Animation}), animated WebPs as
diff --git a/src/metadata.ts b/src/metadata.ts
new file mode 100644
index 0000000..59462db
--- /dev/null
+++ b/src/metadata.ts
@@ -0,0 +1,101 @@
+import { VERSION } from './version.ts';
+import { encode as utf8 } from './util/utf8.ts';
+import * as exif from './codecs/exif.ts';
+
+/**
+ * Metadata written by the encoders. Each format stores the fields it has a place for:
+ *
+ * | Format | Fields |
+ * |---|---|
+ * | PNG, APNG | all (as text chunks) |
+ * | JPEG | author, description, copyright, creation time, software (Exif); comment (comment marker) |
+ * | WebP | author, description, copyright, creation time, software (Exif) |
+ * | GIF | software, comment (comment extensions) |
+ * | TGA | author, comment, creation time, software (extension area) |
+ *
+ * Empty fields are left out
+ */
+export interface Metadata {
+ /** The image's title */
+ title?: string;
+ /** The image's author */
+ author?: string;
+ /** The image's description */
+ description?: string;
+ /** The image's copyright info */
+ copyright?: string;
+ /** The image's creation time. Default: now */
+ creationTime?: string | number | Date;
+ /** The software used to create the image. Default: `'github.com/matmen/ImageScript vX.X.X'`, `''` leaves it out */
+ software?: string;
+ /** A disclaimer for the image */
+ disclaimer?: string;
+ /** A warning for the image */
+ warning?: string;
+ /** The image's source */
+ source?: string;
+ /** A comment for the image */
+ comment?: string;
+}
+
+/** @internal metadata with the defaults applied and empty fields removed */
+export interface ResolvedMetadata {
+ title?: string;
+ author?: string;
+ description?: string;
+ copyright?: string;
+ creationTime: Date;
+ software?: string;
+ disclaimer?: string;
+ warning?: string;
+ source?: string;
+ comment?: string;
+}
+
+const TEXT_FIELDS = ['title', 'author', 'description', 'copyright', 'software', 'disclaimer', 'warning', 'source', 'comment'] as const;
+
+/** @internal */
+export function resolveMetadata(metadata: Metadata = {}): ResolvedMetadata {
+ const { creationTime, software } = metadata || {};
+ const time = new Date(creationTime === undefined ? Date.now() : creationTime);
+ if (isNaN(time.getTime())) throw new RangeError('Invalid creation time');
+
+ const resolved: ResolvedMetadata = { creationTime: time };
+ for (const key of TEXT_FIELDS) {
+ const value = key === 'software' && software === undefined ? `github.com/matmen/ImageScript v${VERSION}` : metadata?.[key];
+ if (value) resolved[key] = String(value);
+ }
+ return resolved;
+}
+
+/** @internal PNG tEXt chunks, keyed by their keywords */
+export function pngText(m: ResolvedMetadata): Record {
+ return {
+ Title: m.title,
+ Author: m.author,
+ Description: m.description,
+ Copyright: m.copyright,
+ 'Creation Time': m.creationTime.toUTCString(),
+ Software: m.software,
+ Disclaimer: m.disclaimer,
+ Warning: m.warning,
+ Source: m.source,
+ Comment: m.comment
+ };
+}
+
+/** @internal Exif data (a TIFF header and IFDs) for JPEG and WebP */
+export function exifData(m: ResolvedMetadata): Uint8Array {
+ return exif.encode({
+ description: m.description,
+ software: m.software,
+ dateTime: m.creationTime,
+ artist: m.author,
+ copyright: m.copyright
+ });
+}
+
+/** @internal GIF comment extensions */
+export function gifComments(m: ResolvedMetadata): Uint8Array[] {
+ return [m.software, m.comment].filter((c): c is string => !!c).map(utf8);
+}
diff --git a/tests/metadata.js b/tests/metadata.js
new file mode 100644
index 0000000..6dbd063
--- /dev/null
+++ b/tests/metadata.js
@@ -0,0 +1,75 @@
+const {Image, Frame, Animation} = require('..');
+
+const panic = message => {
+ console.error(message);
+ process.exit(1);
+};
+
+const has = (data, text) => Buffer.from(data).includes(Buffer.from(text));
+const latin1 = (data, text) => Buffer.from(data).includes(Buffer.from(text, 'latin1'));
+
+(async () => {
+ const {VERSION} = await import('../dist/version.js');
+ const software = `github.com/matmen/ImageScript v${VERSION}`;
+ const image = new Image(8, 4).fill(0xff000080);
+ const animation = new Animation([Frame.from(image, 100), Frame.from(new Image(8, 4).fill(0x00ff00ff), 100)]);
+
+ const encoders = {
+ png: m => image.encode(m),
+ jpeg: m => image.encodeJPEG(90, m),
+ webp: m => image.encodeWebP(m),
+ tga: m => image.encodeTGA(m),
+ gif: m => animation.encodeGIF(95, m),
+ apng: m => animation.encodeAPNG(1, m),
+ 'animated webp': m => animation.encodeWebP(m)
+ };
+
+ // every format names the software by default, '' leaves it out, and the files still decode
+ for (const [format, encode] of Object.entries(encoders)) {
+ const tagged = await encode();
+ if (!has(tagged, software)) panic(`${format}: software missing by default`);
+ if (has(await encode({software: ''}), 'ImageScript')) panic(`${format}: software: '' should leave it out`);
+ if (!has(await encode({software: 'custom tool'}), 'custom tool')) panic(`${format}: custom software missing`);
+
+ const decoded = /gif|apng|animated/.test(format) ? (await Animation.decode(tagged))[0] : await Image.decode(tagged);
+ if (decoded.width !== 8 || decoded.height !== 4) panic(`${format}: doesn't decode with metadata`);
+ }
+
+ const metadata = {author: 'Jörg', description: 'a description', copyright: 'Ⓒ someone', comment: 'a comment', creationTime: Date.UTC(2026, 9, 15, 12, 34, 56)};
+
+ // PNG: tEXt is Latin-1, other text goes into iTXt (UTF-8), before the image data
+ {
+ const png = Buffer.from(await image.encode(metadata));
+ if (!latin1(png, 'Author\0Jörg')) panic('png: Latin-1 text should be a tEXt chunk');
+ if (!has(png, 'iTXtCopyright\0\0\0\0\0Ⓒ someone')) panic('png: other text should be an iTXt chunk');
+ if (png.indexOf('tEXt') > png.indexOf('IDAT')) panic('png: text chunks should come before the image data');
+ if (!has(png, 'Thu, 15 Oct 2026 12:34:56 GMT')) panic('png: creation time missing');
+ }
+
+ // JPEG and WebP: Exif (IFD0 strings, UTC date), JPEG also a comment marker
+ for (const format of ['jpeg', 'webp']) {
+ const file = await encoders[format](metadata);
+ for (const text of ['Exif'.slice(format === 'webp' ? 4 : 0), 'MM\0*', 'Jörg', 'a description', 'Ⓒ someone', '2026:10:15 12:34:56\0', '+00:00'])
+ if (!has(file, text)) panic(`${format}: ${JSON.stringify(text)} missing from the Exif data`);
+ if (format === 'jpeg' && !has(file, Buffer.from([0xff, 0xfe, 0, 11, ...Buffer.from('a comment')]))) panic('jpeg: comment marker missing');
+ }
+
+ // GIF: comment extensions
+ if (!has(await animation.encodeGIF(95, metadata), Buffer.from([0x21, 0xfe, 9, ...Buffer.from('a comment'), 0]))) panic('gif: comment extension missing');
+
+ // TGA: extension area, referenced by the footer
+ {
+ const tga = Buffer.from(await image.encodeTGA({...metadata, comment: 'line 1\n' + 'x'.repeat(100)}));
+ const area = tga.readUInt32LE(tga.length - 26);
+ if (tga.readUInt16LE(area) !== 495) panic('tga: extension area missing');
+ const field = (offset, size) => tga.subarray(area + offset, area + offset + size).toString().split('\0')[0];
+ if (field(2, 41) !== 'Jörg') panic('tga: author missing');
+ // long lines wrap at 80 bytes
+ if (field(43, 81) !== 'line 1' || field(124, 81) !== 'x'.repeat(80) || field(205, 81) !== 'x'.repeat(20)) panic('tga: comment lines wrong');
+ if ([0, 1, 2, 3, 4, 5].map(i => tga.readUInt16LE(area + 367 + i * 2)).join() !== '10,15,2026,12,34,56') panic('tga: date wrong');
+ if (field(426, 41) !== software) panic('tga: software missing');
+ if (tga[area + 494] !== 3) panic('tga: attributes type should be "useful alpha"');
+ }
+
+ if (await image.encode({creationTime: 'not a date'}).then(() => true, () => false)) panic('an invalid creation time should throw');
+})();