From 0305965d4c63ae7a9f1094697d63966115643961 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Thu, 15 Oct 2026 14:52:11 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J --- src/animation.ts | 19 +++++---- src/codecs/exif.ts | 84 +++++++++++++++++++++++++++++++++++++ src/codecs/gif.ts | 10 ++++- src/codecs/jpeg.ts | 18 +++++++- src/codecs/png.ts | 23 ++++++++--- src/codecs/tga.ts | 57 +++++++++++++++++++++++-- src/codecs/webp.ts | 27 ++++++++---- src/gif.ts | 6 ++- src/image.ts | 90 ++++++++++++---------------------------- src/index.ts | 1 + src/metadata.ts | 101 +++++++++++++++++++++++++++++++++++++++++++++ tests/metadata.js | 75 +++++++++++++++++++++++++++++++++ 12 files changed, 420 insertions(+), 91 deletions(-) create mode 100644 src/metadata.ts create mode 100644 tests/metadata.js 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'); +})();