Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions src/animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -118,8 +119,9 @@ export class Animation extends Array<Frame> {
/**
* 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<Uint8Array> {
async encodeGIF(quality = 95, metadata: Metadata = {}): Promise<Uint8Array> {
this.__validate__();
const frames: gifCodec.GIFFrame[] = [];

Expand All @@ -135,34 +137,37 @@ export class Animation extends Array<Frame> {
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<Uint8Array> {
async encodeAPNG(compression = 1, metadata: Metadata = {}): Promise<Uint8Array> {
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<Uint8Array> {
async encodeWebP(metadata: Metadata = {}): Promise<Uint8Array> {
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 => {
Expand All @@ -178,7 +183,7 @@ export class Animation extends Array<Frame> {
}
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) */
Expand Down
84 changes: 84 additions & 0 deletions src/codecs/exif.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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;
}
10 changes: 9 additions & 1 deletion src/codecs/gif.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GIFData, 'loopCount'> & { loopCount?: number }, quality = 95): Uint8Array {
/**
* @param comments written as comment extensions
*/
export function encode({ width, height, loopCount = -1, frames }: Omit<GIFData, 'loopCount'> & { loopCount?: number }, quality = 95, comments: Uint8Array[] = []): Uint8Array {
const out = new ByteWriter();

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

for (const comment of comments) {
out.bytes(0x21, 0xfe);
writeSubBlocks(out, comment);
}

for (const frame of frames) {
const { palette, indices, transparentIndex } = quantize(frame.pixels, frame.width, { quality });
const entries = palette.length / 3;
Expand Down
18 changes: 17 additions & 1 deletion src/codecs/jpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

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

// natural order index of the n-th zigzag coefficient
const ZIGZAG = Int32Array.of(
Expand Down Expand Up @@ -843,7 +844,14 @@ const AAN_SCALE = [1, 1.387039845, 1.306562965, 1.175875602, 1, 0.785694958, 0.5
* @param {number} [quality=90] 1-100
* @return {Uint8Array}
*/
export function encode(pixels: Uint8Array, width: number, height: number, quality = 90): Uint8Array {
export interface EncodeMetadata {
/** Exif data (a TIFF header and IFDs), written as APP1 */
exif?: Uint8Array;
/** written as comment (COM) */
comment?: Uint8Array;
}

export function encode(pixels: Uint8Array, width: number, height: number, quality = 90, { exif, comment }: EncodeMetadata = {}): Uint8Array {
if (!width || !height || width > 0xffff || height > 0xffff) throw new RangeError('JPEG: invalid dimensions');

quality = Math.min(100, Math.max(1, Math.round(quality)));
Expand Down Expand Up @@ -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<number>): 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);
Expand Down
23 changes: 17 additions & 6 deletions src/codecs/png.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const CHANNELS_TO_COLOR_TYPE: Record<number, number> = { 1: 0, 2: 4, 3: 2, 4: 6
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] };

export interface EncodeOptions {
/** tEXt chunks, empty values are skipped */
/** text chunks, empty values are skipped */
text?: Record<string, string | undefined>;
width: number;
height: number;
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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<Uint8Array> {
export async function encodeAnimation(frames: APNGEncodeFrame[], width: number, height: number, plays: number, level: number, text?: EncodeOptions['text']): Promise<Uint8Array> {
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
Expand Down Expand Up @@ -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++) {
Expand Down
57 changes: 53 additions & 4 deletions src/codecs/tga.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading