imagescript 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +12 -4
  2. package/dist/animation.d.ts +92 -7
  3. package/dist/animation.js +49 -19
  4. package/dist/codecs/exif.js +62 -0
  5. package/dist/codecs/gif.js +32 -19
  6. package/dist/codecs/jpeg.js +13 -1
  7. package/dist/codecs/png.js +85 -21
  8. package/dist/codecs/quantize.js +2 -2
  9. package/dist/codecs/tga.js +47 -4
  10. package/dist/codecs/vp8l.js +27 -30
  11. package/dist/codecs/webp.js +23 -10
  12. package/dist/frame.d.ts +30 -2
  13. package/dist/frame.js +20 -9
  14. package/dist/gif.d.ts +10 -7
  15. package/dist/gif.js +7 -5
  16. package/dist/image-type.d.ts +1 -0
  17. package/dist/image-type.js +1 -0
  18. package/dist/image.d.ts +231 -31
  19. package/dist/image.js +111 -50
  20. package/dist/index.d.ts +14 -4
  21. package/dist/index.js +6 -4
  22. package/dist/metadata.d.ts +35 -0
  23. package/dist/metadata.js +43 -0
  24. package/dist/render/svg.js +39 -21
  25. package/dist/util/optimize.js +264 -0
  26. package/dist/util/options.js +3 -0
  27. package/dist/util/resample.js +44 -26
  28. package/dist/util/utf8.js +4 -51
  29. package/dist/version.js +1 -1
  30. package/package.json +9 -2
  31. package/dist/codecs/crc.d.ts +0 -9
  32. package/dist/codecs/exif.d.ts +0 -11
  33. package/dist/codecs/gif.d.ts +0 -44
  34. package/dist/codecs/jpeg.d.ts +0 -35
  35. package/dist/codecs/png.d.ts +0 -97
  36. package/dist/codecs/quantize.d.ts +0 -25
  37. package/dist/codecs/tga.d.ts +0 -16
  38. package/dist/codecs/tiff.d.ts +0 -6
  39. package/dist/codecs/vp8-tables.d.ts +0 -10
  40. package/dist/codecs/vp8.d.ts +0 -8
  41. package/dist/codecs/vp8l.d.ts +0 -62
  42. package/dist/codecs/webp.d.ts +0 -67
  43. package/dist/codecs/zlib.d.ts +0 -22
  44. package/dist/render/font.d.ts +0 -92
  45. package/dist/render/hinting.d.ts +0 -110
  46. package/dist/render/svg.d.ts +0 -11
  47. package/dist/render/text.d.ts +0 -63
  48. package/dist/util/blur.d.ts +0 -13
  49. package/dist/util/resample.d.ts +0 -12
  50. package/dist/util/utf8.d.ts +0 -5
  51. package/dist/version.d.ts +0 -1
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  **ImageScript** is a zero-dependency alternative to common JavaScript bitmap image manipulation tools. Every codec and
11
11
  renderer is written in TypeScript: no WebAssembly, no native binaries, no npm dependencies, no install scripts. The
12
- package is about 140 kB (compressed) and runs in Node.js 22.13 or newer (including Alpine/musl, ARM and bundled
12
+ package is about 160 kB (compressed) and runs in Node.js 22.13 or newer (including Alpine/musl, ARM and bundled
13
13
  serverless functions), Deno, Bun and browsers. It works with both `import` and `require()`, type definitions are included.
14
14
 
15
15
  ```sh
@@ -38,7 +38,8 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
38
38
  - Image manipulation functions ([crop](https://imagescript.matmen.dev/classes/Image.html#crop)
39
39
  , [rotate](https://imagescript.matmen.dev/classes/Image.html#rotate)
40
40
  , [cropCircle](https://imagescript.matmen.dev/classes/Image.html#cropcircle)
41
- , [roundCorners](https://imagescript.matmen.dev/classes/Image.html#roundcorners), ...)
41
+ , [roundCorners](https://imagescript.matmen.dev/classes/Image.html#roundcorners)
42
+ , [trim](https://imagescript.matmen.dev/classes/Image.html#trim), ...)
42
43
  - Filters and color adjustments ([blur](https://imagescript.matmen.dev/classes/Image.html#blur)
43
44
  , [sharpen](https://imagescript.matmen.dev/classes/Image.html#sharpen)
44
45
  , [brightness](https://imagescript.matmen.dev/classes/Image.html#brightness)
@@ -53,7 +54,7 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
53
54
  and [TGAs](https://imagescript.matmen.dev/classes/Image.html#encodetga)
54
55
  - Encoding animations as [GIF](https://imagescript.matmen.dev/classes/Animation.html#encodegif),
55
56
  [APNG](https://imagescript.matmen.dev/classes/Animation.html#encodeapng) and lossless
56
- [WebP](https://imagescript.matmen.dev/classes/Animation.html#encodewebp)
57
+ [WebP](https://imagescript.matmen.dev/classes/Animation.html#encodewebp), storing only what changes from frame to frame
57
58
 
58
59
  ---
59
60
 
@@ -77,7 +78,7 @@ const { Image } = require('imagescript');
77
78
  All coordinates are 0-based, `(0, 0)` is the top left pixel.
78
79
 
79
80
  A bigger example is the [README image generation](https://github.com/matmen/ImageScript/blob/master/tests/readme.js).
80
- Upgrading from 1.x? Version 3 has breaking changes (0-based coordinates, removed WebP encoding, Node.js 22.13+, ...),
81
+ Upgrading from 1.x? Version 3 has breaking changes (0-based coordinates, removed lossy WebP encoding, Node.js 22.13+, ...),
81
82
  see the [changelog](https://github.com/matmen/ImageScript/blob/master/CHANGELOG.md). There is no 2.x release, the number was skipped because of the old experimental
82
83
  `imagescript/v2` API.
83
84
 
@@ -85,6 +86,13 @@ see the [changelog](https://github.com/matmen/ImageScript/blob/master/CHANGELOG.
85
86
 
86
87
  ---
87
88
 
89
+ ### Benchmarks
90
+
91
+ [bench/](bench/README.md) compares ImageScript to Jimp, image-js, Photon, sharp and @napi-rs/canvas: speed of decoding,
92
+ encoding, image operations and rendering, encoded file sizes and install sizes. It runs for every release.
93
+
94
+ ---
95
+
88
96
  ### Credits
89
97
 
90
98
  The deflate/inflate implementation is derived from [fflate](https://github.com/101arrowz/fflate) and the CRC-32 from
@@ -1,19 +1,67 @@
1
1
  import { BinaryData } from './util/mem.js';
2
- import { ResizeMode } from './image.js';
2
+ import { ResizeMode, ResizeOptions } from './image.js';
3
3
  import { Frame } from './frame.js';
4
+ import { Metadata } from './metadata.js';
5
+ /** Options of {@link Animation} */
6
+ export interface AnimationOptions {
7
+ /** How many times to repeat the animation (-1 = forever, 0 = play once). Default: `-1` */
8
+ loopCount?: number;
9
+ }
10
+ /** Options of {@link Animation.decode} */
11
+ export interface AnimationDecodeOptions {
12
+ /** Whether to stop decoding after the first frame. Default: `false` */
13
+ onlyFirstFrame?: boolean;
14
+ }
15
+ /** Options of {@link Animation.encodeWebP}, and those all animation encoders have */
16
+ export interface AnimationEncodeOptions {
17
+ /** Metadata to include, see {@link Metadata} */
18
+ metadata?: Metadata;
19
+ /**
20
+ * Whether to store each frame as the area that changed since the frame before and merge identical frames, which
21
+ * usually makes files a lot smaller. The file shows the same animation either way, but only decodes to the same
22
+ * frames without it, which also encodes faster when little changes. Default: `true`
23
+ */
24
+ optimize?: boolean;
25
+ }
26
+ /** Options of {@link Animation.encodeGIF} */
27
+ export interface GIFEncodeOptions extends AnimationEncodeOptions {
28
+ /** 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above). Default: `95` */
29
+ quality?: number;
30
+ }
31
+ /** Options of {@link Animation.encodeAPNG} */
32
+ export interface APNGEncodeOptions extends AnimationEncodeOptions {
33
+ /** The zlib compression level (0-9), higher is smaller but slower. Default: `1` */
34
+ compression?: number;
35
+ }
4
36
  /**
5
- * An animation: an array of frames that can be decoded from and encoded to GIF, APNG and WebP.
6
- * Array methods like map and filter return plain arrays.
37
+ * An animation: frames ({@link Animation.frames}) that can be decoded from and encoded to GIF, APNG and WebP.
38
+ *
39
+ * The animation itself is an array of its frames too (`animation[0]`, `animation.map(...)`), which is deprecated: in 4.0
40
+ * it won't extend `Array` anymore, use {@link Animation.frames} instead. Array methods like map and filter return
41
+ * plain arrays.
7
42
  */
8
43
  export declare class Animation extends Array<Frame> {
9
44
  /** How many times to repeat the animation (-1 = forever, 0 = play once) */
10
45
  loopCount: number;
46
+ /**
47
+ * Creates a new animation
48
+ * @param frames The frames to create the animation from
49
+ * @param options See {@link AnimationOptions}
50
+ */
51
+ constructor(frames: Frame[], options?: AnimationOptions);
11
52
  /**
12
53
  * Creates a new animation
13
54
  * @param frames The frames to create the animation from
14
55
  * @param loopCount How many times to repeat the animation (-1 = forever, 0 = play once)
15
56
  */
16
57
  constructor(frames: Frame[], loopCount?: number);
58
+ /**
59
+ * The animation's frames: an array that changes the animation when it's changed (e.g. `push()`, `splice()`, or
60
+ * setting an index), and can be replaced as a whole
61
+ */
62
+ get frames(): Frame[];
63
+ /** Replaces the animation's frames */
64
+ set frames(frames: Frame[]);
17
65
  /** The animation's width */
18
66
  get width(): number;
19
67
  /** The animation's height */
@@ -24,28 +72,65 @@ export declare class Animation extends Array<Frame> {
24
72
  /**
25
73
  * Returns a new animation of the same class containing a range of this one's frames
26
74
  * (same semantics as Array.prototype.slice)
75
+ * @deprecated Use `new Animation(animation.frames.slice(start, end), animation.loopCount)`: in 4.0 animations
76
+ * aren't arrays anymore
27
77
  */
28
78
  slice(start?: number, end?: number): this;
79
+ /**
80
+ * Resizes the animation to the given dimensions, scaling every frame and its offsets proportionally.
81
+ * Use {@link Image.RESIZE_AUTO} as either width or height to automatically preserve the aspect ratio.
82
+ */
83
+ resize(width: number, height: number, options?: ResizeOptions): this;
29
84
  /**
30
85
  * Resizes the animation to the given dimensions, scaling every frame and its offsets proportionally.
31
86
  * Use {@link Image.RESIZE_AUTO} as either width or height to automatically preserve the aspect ratio.
32
87
  */
33
88
  resize(width: number, height: number, mode?: ResizeMode): this;
34
89
  /**
35
- * Encodes the animation as GIF
90
+ * Encodes the animation as GIF. Like all encoders, it stores each frame as the area that changed since the frame
91
+ * before and merges identical frames (unless the `optimize` option is false), so the file decodes to what the
92
+ * animation shows, but not necessarily to the same frames. Semi-transparent pixels become opaque or transparent
93
+ * after the frames are composited
94
+ * @param options See {@link GIFEncodeOptions}
95
+ */
96
+ encodeGIF(options?: GIFEncodeOptions): Promise<Uint8Array>;
97
+ /**
98
+ * Encodes the animation as GIF. Like all encoders, it stores each frame as the area that changed since the frame
99
+ * before and merges identical frames (unless the `optimize` option is false), so the file decodes to what the
100
+ * animation shows, but not necessarily to the same frames. Semi-transparent pixels become opaque or transparent
101
+ * after the frames are composited
36
102
  * @param quality 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above)
37
103
  */
38
104
  encodeGIF(quality?: number): Promise<Uint8Array>;
39
105
  /**
40
106
  * Encodes the animation as APNG (animated PNG, lossless and with full alpha). Viewers without APNG support show
41
- * the first frame
107
+ * the first frame. Each frame is stored as the area that changed since the frame before, identical frames are
108
+ * merged (unless the `optimize` option is false): the file shows exactly the same animation, but doesn't
109
+ * necessarily decode to the same frames
110
+ * @param options See {@link APNGEncodeOptions}
111
+ */
112
+ encodeAPNG(options?: APNGEncodeOptions): Promise<Uint8Array>;
113
+ /**
114
+ * Encodes the animation as APNG (animated PNG, lossless and with full alpha). Viewers without APNG support show
115
+ * the first frame. Each frame is stored as the area that changed since the frame before, identical frames are
116
+ * merged (unless the `optimize` option is false): the file shows exactly the same animation, but doesn't
117
+ * necessarily decode to the same frames
42
118
  * @param compression The zlib compression level (0-9), higher is smaller but slower
43
119
  */
44
120
  encodeAPNG(compression?: number): Promise<Uint8Array>;
45
121
  /**
46
- * Encodes the animation as lossless animated WebP
122
+ * Encodes the animation as lossless animated WebP. Each frame is stored as the area that changed since the frame
123
+ * before, identical frames are merged (unless the `optimize` option is false): the file shows exactly the same
124
+ * animation, but doesn't necessarily decode to the same frames
125
+ * @param options See {@link AnimationEncodeOptions}
126
+ */
127
+ encodeWebP({ metadata, optimize }?: AnimationEncodeOptions): Promise<Uint8Array>;
128
+ /**
129
+ * Decodes an animated GIF, APNG or WebP into fully composited frames (still images become a single frame)
130
+ * @param input The binary data to decode
131
+ * @param options See {@link AnimationDecodeOptions}
47
132
  */
48
- encodeWebP(): Promise<Uint8Array>;
133
+ static decode(input: BinaryData, options?: AnimationDecodeOptions): Promise<Animation>;
49
134
  /**
50
135
  * Decodes an animated GIF, APNG or WebP into fully composited frames (still images become a single frame)
51
136
  * @param input The binary data to decode
package/dist/animation.js CHANGED
@@ -5,6 +5,9 @@ import { view } from "./util/mem.js";
5
5
  import { Image } from "./image.js";
6
6
  import { Frame } from "./frame.js";
7
7
  import { ImageType } from "./image-type.js";
8
+ import { optimize } from "./util/optimize.js";
9
+ import { resolveMetadata, pngText, exifData, gifComments } from "./metadata.js";
10
+ import { isOptions } from "./util/options.js";
8
11
  const toPlays = (loopCount) => loopCount < 0 ? 0 : loopCount + 1;
9
12
  const fromPlays = (plays) => plays === 0 ? -1 : plays - 1;
10
13
  function canvasFrames(width, height, frames) {
@@ -16,8 +19,9 @@ function canvasFrames(width, height, frames) {
16
19
  }
17
20
  export class Animation extends Array {
18
21
  loopCount;
19
- constructor(frames, loopCount = -1) {
22
+ constructor(frames, loopCountOrOptions = -1) {
20
23
  super();
24
+ const { loopCount = -1 } = isOptions(loopCountOrOptions) ? loopCountOrOptions : { loopCount: loopCountOrOptions };
21
25
  for (const frame of frames)
22
26
  this.push(frame);
23
27
  for (let i = 0; i < this.length; i++)
@@ -30,6 +34,19 @@ export class Animation extends Array {
30
34
  static get [Symbol.species]() {
31
35
  return Array;
32
36
  }
37
+ get frames() {
38
+ return this;
39
+ }
40
+ set frames(frames) {
41
+ frames = Array.from(frames);
42
+ frames.forEach((frame, i) => {
43
+ if (!(frame instanceof Frame))
44
+ throw new TypeError(`Frame ${i} is not an instance of Frame`);
45
+ });
46
+ this.length = 0;
47
+ for (const frame of frames)
48
+ this.push(frame);
49
+ }
33
50
  get width() {
34
51
  let max = 0;
35
52
  for (const frame of this)
@@ -55,7 +72,8 @@ export class Animation extends Array {
55
72
  const Class = this.constructor;
56
73
  return new Class(Array.prototype.slice.call(this, start, end), this.loopCount);
57
74
  }
58
- resize(width, height, mode = Image.RESIZE_BILINEAR) {
75
+ resize(width, height, modeOrOptions) {
76
+ const mode = (isOptions(modeOrOptions) ? modeOrOptions.mode : modeOrOptions) ?? Image.RESIZE_BILINEAR;
59
77
  const originalWidth = this.width, originalHeight = this.height;
60
78
  if (width === Image.RESIZE_AUTO && height === Image.RESIZE_AUTO)
61
79
  throw new Error('RESIZE_AUTO can only be used for either width or height, not for both');
@@ -78,38 +96,47 @@ export class Animation extends Array {
78
96
  if (!(frame instanceof Frame))
79
97
  throw new Error('Animation contains invalid frames');
80
98
  }
81
- async encodeGIF(quality = 95) {
99
+ async encodeGIF(qualityOrOptions = 95) {
100
+ const { quality = 95, metadata = {}, optimize = true } = isOptions(qualityOrOptions) ? qualityOrOptions : { quality: qualityOrOptions };
82
101
  this.__validate__();
83
- const frames = [];
84
- for (const frame of this)
85
- frames.push({
102
+ const frames = optimize
103
+ ? this.__optimized__({ replace: false, even: false, alpha: 'frame', palette: 256, cover: true }).map(f => ({ ...f, dispose: f.dispose ? 3 : 1 }))
104
+ : Array.from(this, frame => ({
86
105
  x: frame.xOffset,
87
106
  y: frame.yOffset,
88
107
  width: frame.width,
89
108
  height: frame.height,
90
- delay: Math.round(frame.duration / 10),
109
+ duration: frame.duration,
91
110
  dispose: frame.__disposal_id__,
92
111
  pixels: new Uint8Array(frame.bitmap.buffer, frame.bitmap.byteOffset, frame.bitmap.byteLength)
93
- });
94
- return gifCodec.encode({ width: this.width, height: this.height, loopCount: this.loopCount, frames }, quality);
112
+ }));
113
+ const gifFrames = frames.map(({ duration, ...f }) => ({ ...f, delay: Math.round(duration / 10) }));
114
+ return gifCodec.encode({ width: this.width, height: this.height, loopCount: this.loopCount, frames: gifFrames }, quality, gifComments(resolveMetadata(metadata)));
95
115
  }
96
- async encodeAPNG(compression = 1) {
116
+ async encodeAPNG(compressionOrOptions = 1) {
117
+ const { compression = 1, metadata = {}, optimize = true } = isOptions(compressionOrOptions) ? compressionOrOptions : { compression: compressionOrOptions };
97
118
  this.__validate__();
98
119
  if (!this.length)
99
120
  throw new Error('An animation needs at least one frame');
100
- return png.encodeAnimation(this.__apngFrames__(), this.width, this.height, toPlays(this.loopCount), compression);
121
+ const frames = optimize
122
+ ? this.__optimized__({ replace: true, even: false, alpha: frames => png.alphaCost(frames.map(f => f.pixels)) }).map(f => ({ ...f, dispose: f.dispose ? 1 : 0 }))
123
+ : this.__apngFrames__().map(({ blend: _, ...f }) => f);
124
+ return png.encodeAnimation(frames, this.width, this.height, toPlays(this.loopCount), compression, pngText(resolveMetadata(metadata)));
101
125
  }
102
- async encodeWebP() {
126
+ async encodeWebP({ metadata = {}, optimize = true } = {}) {
103
127
  this.__validate__();
104
128
  if (!this.length)
105
129
  throw new Error('An animation needs at least one frame');
130
+ const frames = optimize ? this.__optimized__({ replace: true, even: true, alpha: 'frame' }) : this.__webpFrames__();
131
+ return webp.encodeAnimation(frames, this.width, this.height, toPlays(this.loopCount), exifData(resolveMetadata(metadata)));
132
+ }
133
+ __webpFrames__() {
106
134
  const width = this.width, height = this.height;
107
135
  const fullFrames = this.some(f => f.disposalMode === 'previous' || (f.disposalMode === 'background' && (f.xOffset & 1 || f.yOffset & 1)));
108
- if (fullFrames) {
109
- const canvases = png.compositeAnimation({ width, height, frames: this.__apngFrames__() });
110
- 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));
111
- }
112
- const frames = Array.from(this, frame => {
136
+ if (fullFrames)
137
+ return png.compositeAnimation({ width, height, frames: this.__apngFrames__() })
138
+ .map(c => ({ x: 0, y: 0, width, height, duration: c.duration, dispose: true, pixels: c.pixels }));
139
+ return Array.from(this, frame => {
113
140
  let { xOffset: x, yOffset: y, width: w, height: h } = frame;
114
141
  let pixels = new Uint8Array(frame.bitmap.buffer, frame.bitmap.byteOffset, frame.bitmap.byteLength);
115
142
  if (x & 1 || y & 1) {
@@ -125,7 +152,9 @@ export class Animation extends Array {
125
152
  }
126
153
  return { x, y, width: w, height: h, duration: frame.duration, dispose: frame.disposalMode === 'background', pixels };
127
154
  });
128
- return webp.encodeAnimation(frames, width, height, toPlays(this.loopCount));
155
+ }
156
+ __optimized__(options) {
157
+ return optimize(this.__apngFrames__(), this.width, this.height, options);
129
158
  }
130
159
  __apngFrames__() {
131
160
  const dispose = [0, 0, 2, 1];
@@ -140,7 +169,8 @@ export class Animation extends Array {
140
169
  pixels: new Uint8Array(frame.bitmap.buffer, frame.bitmap.byteOffset, frame.bitmap.byteLength)
141
170
  }));
142
171
  }
143
- static async decode(input, onlyExtractFirstFrame = false) {
172
+ static async decode(input, onlyFirstFrameOrOptions = false) {
173
+ const onlyExtractFirstFrame = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions.onlyFirstFrame ?? false : onlyFirstFrameOrOptions;
144
174
  const data = view(input);
145
175
  const type = ImageType.getType(data);
146
176
  if (type === 'gif')
@@ -1,3 +1,5 @@
1
+ import { concat } from "../util/mem.js";
2
+ import { encode as utf8 } from "../util/utf8.js";
1
3
  export function orientation(data) {
2
4
  if (data.length >= 6 && data[0] === 0x45 && data[1] === 0x78 && data[2] === 0x69 && data[3] === 0x66 && data[4] === 0 && data[5] === 0)
3
5
  data = data.subarray(6);
@@ -25,3 +27,63 @@ export function xmpOrientation(xmp) {
25
27
  const match = /tiff:Orientation\s*(?:=\s*["']|>\s*)([1-8])\b/.exec(xmp);
26
28
  return match ? +match[1] : 1;
27
29
  }
30
+ const ASCII = 2, LONG = 4, UNDEFINED = 7;
31
+ const ascii = (tag, text) => {
32
+ const data = concat([utf8(text), [0]]);
33
+ return { tag, type: ASCII, count: data.length, data };
34
+ };
35
+ const pad = (n) => String(n).padStart(2, '0');
36
+ export function encode(fields) {
37
+ const ifd0 = [];
38
+ const exifIFD = [];
39
+ if (fields.description)
40
+ ifd0.push(ascii(0x010e, fields.description));
41
+ if (fields.software)
42
+ ifd0.push(ascii(0x0131, fields.software));
43
+ if (fields.dateTime) {
44
+ const d = fields.dateTime;
45
+ const time = `${d.getUTCFullYear()}:${pad(d.getUTCMonth() + 1)}:${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
46
+ ifd0.push(ascii(0x0132, time));
47
+ exifIFD.push({ tag: 0x9000, type: UNDEFINED, count: 4, data: Uint8Array.of(0x30, 0x32, 0x33, 0x32) });
48
+ exifIFD.push(ascii(0x9003, time));
49
+ exifIFD.push(ascii(0x9010, '+00:00'));
50
+ exifIFD.push(ascii(0x9011, '+00:00'));
51
+ }
52
+ if (fields.artist)
53
+ ifd0.push(ascii(0x013b, fields.artist));
54
+ if (fields.copyright)
55
+ ifd0.push(ascii(0x8298, fields.copyright));
56
+ if (exifIFD.length)
57
+ ifd0.push({ tag: 0x8769, type: LONG, count: 1, data: new Uint8Array(4) });
58
+ const size = (entries) => 2 + entries.length * 12 + 4 + entries.reduce((n, e) => n + (e.data.length > 4 ? e.data.length + (e.data.length & 1) : 0), 0);
59
+ const ifd0Offset = 8;
60
+ const exifOffset = ifd0Offset + size(ifd0);
61
+ const out = new Uint8Array(exifOffset + (exifIFD.length ? size(exifIFD) : 0));
62
+ const view = new DataView(out.buffer);
63
+ out.set([0x4d, 0x4d, 0x00, 0x2a]);
64
+ view.setUint32(4, ifd0Offset);
65
+ const pointer = ifd0.find(e => e.tag === 0x8769);
66
+ if (pointer)
67
+ new DataView(pointer.data.buffer).setUint32(0, exifOffset);
68
+ const write = (entries, offset) => {
69
+ view.setUint16(offset, entries.length);
70
+ let data = offset + 2 + entries.length * 12 + 4;
71
+ entries.forEach((e, i) => {
72
+ const at = offset + 2 + i * 12;
73
+ view.setUint16(at, e.tag);
74
+ view.setUint16(at + 2, e.type);
75
+ view.setUint32(at + 4, e.count);
76
+ if (e.data.length <= 4)
77
+ out.set(e.data, at + 8);
78
+ else {
79
+ view.setUint32(at + 8, data);
80
+ out.set(e.data, data);
81
+ data += e.data.length + (e.data.length & 1);
82
+ }
83
+ });
84
+ };
85
+ write(ifd0, ifd0Offset);
86
+ if (exifIFD.length)
87
+ write(exifIFD, exifOffset);
88
+ return out;
89
+ }
@@ -3,7 +3,7 @@ const DISPOSAL_TO_GIF = [0, 1, 3, 2];
3
3
  const DISPOSAL_FROM_GIF = [0, 1, 3, 2, 0, 0, 0, 0];
4
4
  const MAX_CODE = 4095;
5
5
  const UNEXPECTED_END = new Error('GIF: unexpected end of data');
6
- export function encode({ width, height, loopCount = -1, frames }, quality = 95) {
6
+ export function encode({ width, height, loopCount = -1, frames }, quality = 95, comments = []) {
7
7
  const out = new ByteWriter();
8
8
  out.bytes(0x47, 0x49, 0x46, 0x38, 0x39, 0x61);
9
9
  out.u16(width);
@@ -16,8 +16,14 @@ export function encode({ width, height, loopCount = -1, frames }, quality = 95)
16
16
  out.u16(loopCount === -1 ? 0 : loopCount);
17
17
  out.bytes(0x00);
18
18
  }
19
- for (const frame of frames) {
20
- const { palette, indices, transparentIndex } = quantize(frame.pixels, frame.width, { quality });
19
+ for (const comment of comments) {
20
+ out.bytes(0x21, 0xfe);
21
+ writeSubBlocks(out, comment);
22
+ }
23
+ const restores = frames.some(frame => frame.dispose === 3);
24
+ for (const [i, frame] of frames.entries()) {
25
+ const transparent = frame.dispose === 3 || (i === 0 && restores);
26
+ const { palette, indices, transparentIndex } = quantize(frame.pixels, frame.width, { quality, transparent });
21
27
  const entries = palette.length / 3;
22
28
  const bits = Math.max(1, Math.ceil(Math.log2(entries)));
23
29
  out.bytes(0x21, 0xf9, 0x04);
@@ -184,20 +190,19 @@ export function decode(data, onlyFirstFrame = false) {
184
190
  continue;
185
191
  const pixels = new Uint8Array(fw * fh * 4);
186
192
  const rows = interlaced ? interlacedRows(fh) : null;
193
+ const colors = new Uint8Array(256 * 4);
194
+ for (let index = 0; index < 256; index++) {
195
+ if (index === transparentIndex)
196
+ continue;
197
+ if (table && index * 3 + 2 < table.length)
198
+ colors.set(table.subarray(index * 3, index * 3 + 3), index * 4);
199
+ colors[index * 4 + 3] = 0xff;
200
+ }
201
+ const lookup = new Uint32Array(colors.buffer), out = new Uint32Array(pixels.buffer);
187
202
  for (let row = 0; row < fh; row++) {
188
- const targetRow = rows ? rows[row] : row;
189
- for (let column = 0; column < fw; column++) {
190
- const index = indices[row * fw + column];
191
- if (index === transparentIndex)
192
- continue;
193
- const o = (targetRow * fw + column) * 4;
194
- if (table && index * 3 + 2 < table.length) {
195
- pixels[o] = table[index * 3];
196
- pixels[o + 1] = table[index * 3 + 1];
197
- pixels[o + 2] = table[index * 3 + 2];
198
- }
199
- pixels[o + 3] = 0xff;
200
- }
203
+ const target = (rows ? rows[row] : row) * fw, source = row * fw;
204
+ for (let column = 0; column < fw; column++)
205
+ out[target + column] = lookup[indices[source + column]];
201
206
  }
202
207
  frames.push({ x, y, width: fw, height: fh, delay, dispose, pixels });
203
208
  if (onlyFirstFrame)
@@ -287,10 +292,18 @@ function lzwDecode(data, minCodeSize, pixelCount) {
287
292
  const length = lengths[entry];
288
293
  const end = Math.min(out + length, pixelCount);
289
294
  let c = entry;
290
- for (let i = out + length - 1; i >= out; i--) {
291
- if (i < end)
295
+ if (end === out + length) {
296
+ for (let i = end - 1; i >= out; i--) {
292
297
  output[i] = suffix[c];
293
- c = prefix[c];
298
+ c = prefix[c];
299
+ }
300
+ }
301
+ else {
302
+ for (let i = out + length - 1; i >= out; i--) {
303
+ if (i < end)
304
+ output[i] = suffix[c];
305
+ c = prefix[c];
306
+ }
294
307
  }
295
308
  let written = end - out;
296
309
  if (code === next) {
@@ -5,6 +5,7 @@
5
5
  * (Copyright 2014 Mozilla Foundation, Apache License 2.0). See THIRD_PARTY_NOTICES.md
6
6
  */
7
7
  import { orientation as exifOrientation } from "./exif.js";
8
+ import { concat } from "../util/mem.js";
8
9
  const ZIGZAG = Int32Array.of(0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63);
9
10
  function clamp8(v) {
10
11
  return v < 0 ? 0 : v > 255 ? 255 : v;
@@ -684,7 +685,7 @@ const CHROMA_QUANTIZATION = [
684
685
  99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99
685
686
  ];
686
687
  const AAN_SCALE = [1, 1.387039845, 1.306562965, 1.175875602, 1, 0.785694958, 0.5411961, 0.275899379];
687
- export function encode(pixels, width, height, quality = 90) {
688
+ export function encode(pixels, width, height, quality = 90, { exif, comment } = {}) {
688
689
  if (!width || !height || width > 0xffff || height > 0xffff)
689
690
  throw new RangeError('JPEG: invalid dimensions');
690
691
  quality = Math.min(100, Math.max(1, Math.round(quality)));
@@ -769,6 +770,17 @@ export function encode(pixels, width, height, quality = 90) {
769
770
  const writer = new BitWriter(width * height);
770
771
  writer.bytes(0xff, 0xd8);
771
772
  writer.bytes(0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00);
773
+ const segment = (marker, body) => {
774
+ if (body.length > 65533)
775
+ throw new RangeError('JPEG: metadata too large');
776
+ writer.bytes(0xff, marker, body.length + 2 >> 8, body.length + 2 & 255);
777
+ for (let i = 0; i < body.length; i++)
778
+ writer.bytes(body[i]);
779
+ };
780
+ if (exif)
781
+ segment(0xe1, concat([[0x45, 0x78, 0x69, 0x66, 0, 0], exif]));
782
+ if (comment)
783
+ segment(0xfe, comment);
772
784
  writer.bytes(0xff, 0xdb, 0x00, 0x84);
773
785
  for (let t = 0; t < 2; t++) {
774
786
  writer.bytes(t);