@cjser/sindresorhus__gifkit 0.1.0-cjser.2

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.
@@ -0,0 +1,453 @@
1
+ import {
2
+ extensionIntroducer,
3
+ imageSeparator,
4
+ trailerByte,
5
+ graphicControlExtensionLabel,
6
+ commentExtensionLabel,
7
+ plainTextExtensionLabel,
8
+ appExtensionLabel,
9
+ defaultMaximumPixelCount,
10
+ defaultMaximumBlockCount,
11
+ defaultMaximumDataSubBlockCount,
12
+ defaultMaximumDataPayloadByteLength,
13
+ uint8ArraySubarray,
14
+ disposalMethodNames,
15
+ } from './constants.js';
16
+ import {requireCountWithinLimit, requireByteLengthWithinLimit, requirePixelCountWithinLimit} from './validate.js';
17
+ import {GIFByteReader} from './byte-stream.js';
18
+ import {decodeCompressedIndexStream} from './lzw.js';
19
+ import {decodeNetscapeLoopCount, isNetscapeLoopingAppExtension} from './loop-count.js';
20
+
21
+ export function decodeGIF(inputBytes, options = {}) {
22
+ const {
23
+ strict = true,
24
+ } = options;
25
+
26
+ const byteReader = new GIFByteReader(inputBytes);
27
+ const header = readGIFHeader(byteReader, {strict});
28
+ const stream = readGIFBlockStream(byteReader, {
29
+ ...header,
30
+ strict,
31
+ });
32
+
33
+ if (strict && byteReader.offset !== byteReader.length) {
34
+ throw new Error(`Found ${byteReader.length - byteReader.offset} trailing byte(s) after the GIF trailer`);
35
+ }
36
+
37
+ return {
38
+ type: 'gif',
39
+ version: header.version,
40
+ width: header.logicalScreenWidth,
41
+ height: header.logicalScreenHeight,
42
+ backgroundColorIndex: header.backgroundColorIndex,
43
+ globalColorTable: header.globalColorTable,
44
+ ...(stream.playCount !== undefined && {playCount: stream.playCount}),
45
+ blocks: stream.blocks,
46
+ imageBlocks: stream.imageBlocks,
47
+ };
48
+ }
49
+
50
+ function readGIFHeader(byteReader, {strict}) {
51
+ const signature = byteReader.readAsciiString(3);
52
+ if (signature !== 'GIF') {
53
+ throw new Error(`Expected GIF signature, got ${JSON.stringify(signature)}`);
54
+ }
55
+
56
+ const version = byteReader.readAsciiString(3);
57
+ if (version !== '87a' && version !== '89a') {
58
+ throw new Error(`Unsupported GIF version ${JSON.stringify(version)}`);
59
+ }
60
+
61
+ const logicalScreenWidth = byteReader.readUnsignedLittleEndian16();
62
+ const logicalScreenHeight = byteReader.readUnsignedLittleEndian16();
63
+ if (strict && (logicalScreenWidth === 0 || logicalScreenHeight === 0)) {
64
+ throw new Error('Logical Screen dimensions must be non-zero in strict mode');
65
+ }
66
+
67
+ const packedLogicalScreenField = byteReader.readByte();
68
+ const hasGlobalColorTable = (packedLogicalScreenField & 0b1000_0000) !== 0;
69
+ const globalColorTableSizeField = packedLogicalScreenField & 0b0000_0111;
70
+ const backgroundColorIndex = byteReader.readByte();
71
+ // The GIF pixel-aspect-ratio byte is intentionally ignored. Modern GIFs are square-pixel images and exposing the raw legacy byte is not useful.
72
+ byteReader.readByte();
73
+
74
+ const globalColorTable = hasGlobalColorTable
75
+ ? readColorTable(byteReader, globalColorTableSizeField)
76
+ : undefined;
77
+
78
+ if (
79
+ strict
80
+ && globalColorTable !== undefined
81
+ && backgroundColorIndex >= globalColorTable.length / 3
82
+ ) {
83
+ throw new Error('Background color index must be inside the global color table in strict mode');
84
+ }
85
+
86
+ if (strict && globalColorTable === undefined && backgroundColorIndex !== 0) {
87
+ throw new Error('Background color index must be zero when there is no global color table in strict mode');
88
+ }
89
+
90
+ return {
91
+ version,
92
+ logicalScreenWidth,
93
+ logicalScreenHeight,
94
+ backgroundColorIndex,
95
+ globalColorTable,
96
+ };
97
+ }
98
+
99
+ function readGIFBlockStream(byteReader, {version, logicalScreenWidth, logicalScreenHeight, globalColorTable, strict}) {
100
+ const blocks = [];
101
+ const imageBlocks = [];
102
+ let pendingGraphicControlExtension;
103
+ let playCount;
104
+ let blockCount = 0;
105
+
106
+ while (true) {
107
+ const nextByte = byteReader.readByte();
108
+
109
+ if (nextByte === trailerByte) {
110
+ break;
111
+ }
112
+
113
+ blockCount += 1;
114
+ requireCountWithinLimit(blockCount, defaultMaximumBlockCount, 'block count');
115
+
116
+ if (nextByte === imageSeparator) {
117
+ const imageBlock = readImageBlock({
118
+ byteReader,
119
+ logicalScreenWidth,
120
+ logicalScreenHeight,
121
+ globalColorTable,
122
+ graphicControlExtension: pendingGraphicControlExtension,
123
+ strict,
124
+ });
125
+ blocks.push(imageBlock);
126
+ imageBlocks.push(imageBlock);
127
+ pendingGraphicControlExtension = undefined;
128
+ continue;
129
+ }
130
+
131
+ if (nextByte !== extensionIntroducer) {
132
+ throw new Error(`Unexpected block introducer 0x${nextByte.toString(16).padStart(2, '0')} at byte offset ${byteReader.offset - 1}`);
133
+ }
134
+
135
+ const extensionLabel = byteReader.readByte();
136
+ if (strict && version === '87a' && isGIF89aExtensionLabel(extensionLabel)) {
137
+ throw new Error(`GIF87a streams cannot contain extension label 0x${extensionLabel.toString(16).padStart(2, '0')}`);
138
+ }
139
+
140
+ switch (extensionLabel) {
141
+ case graphicControlExtensionLabel: {
142
+ if (pendingGraphicControlExtension !== undefined && strict) {
143
+ throw new Error('A second Graphic Control Extension appeared before a graphic rendering block');
144
+ }
145
+
146
+ pendingGraphicControlExtension = readGraphicControlExtension(byteReader, {strict});
147
+ break;
148
+ }
149
+
150
+ case commentExtensionLabel: {
151
+ const commentBlock = {
152
+ type: 'commentExtension',
153
+ data: readDataSubBlocks(byteReader),
154
+ };
155
+ blocks.push(commentBlock);
156
+ break;
157
+ }
158
+
159
+ case plainTextExtensionLabel: {
160
+ blocks.push(readUnknownExtension(byteReader, plainTextExtensionLabel));
161
+ pendingGraphicControlExtension = undefined;
162
+ break;
163
+ }
164
+
165
+ case appExtensionLabel: {
166
+ const appBlock = readAppExtension(byteReader, {strict});
167
+ blocks.push(appBlock);
168
+ if (isNetscapeLoopingAppExtension(appBlock)) {
169
+ playCount = decodeNetscapeLoopCount(appBlock.data[1] | (appBlock.data[2] << 8));
170
+ appBlock.isNetscapeLoopingExtension = true;
171
+ appBlock.playCount = playCount;
172
+ }
173
+
174
+ break;
175
+ }
176
+
177
+ default: {
178
+ const unknownExtensionBlock = version === '87a'
179
+ ? readGIF87aUnknownExtension(byteReader, extensionLabel)
180
+ : readUnknownExtension(byteReader, extensionLabel);
181
+ blocks.push(unknownExtensionBlock);
182
+ if (isGraphicRenderingExtensionLabel(extensionLabel)) {
183
+ pendingGraphicControlExtension = undefined;
184
+ }
185
+
186
+ break;
187
+ }
188
+ }
189
+ }
190
+
191
+ if (pendingGraphicControlExtension !== undefined && strict) {
192
+ throw new Error('A Graphic Control Extension was not followed by a graphic rendering block');
193
+ }
194
+
195
+ return {
196
+ playCount,
197
+ blocks,
198
+ imageBlocks,
199
+ };
200
+ }
201
+
202
+ function readImageBlock({byteReader, logicalScreenWidth, logicalScreenHeight, globalColorTable, graphicControlExtension, strict}) {
203
+ const descriptor = readImageDescriptor(byteReader, {
204
+ logicalScreenWidth,
205
+ logicalScreenHeight,
206
+ strict,
207
+ });
208
+ const {
209
+ left,
210
+ top,
211
+ width,
212
+ height,
213
+ hasLocalColorTable,
214
+ isInterlaced,
215
+ colorTableSizeField,
216
+ } = descriptor;
217
+ const colorTable = hasLocalColorTable
218
+ ? readColorTable(byteReader, colorTableSizeField)
219
+ : undefined;
220
+ const activeColorTable = colorTable ?? globalColorTable;
221
+ if (strict && activeColorTable === undefined) {
222
+ throw new Error('Image block requires an active color table in strict mode');
223
+ }
224
+
225
+ validateTransparentColorIndex(graphicControlExtension, activeColorTable, {strict});
226
+
227
+ const minimumCodeSize = byteReader.readByte();
228
+ if (minimumCodeSize < 2 || minimumCodeSize > 8) {
229
+ throw new Error(`Invalid minimum code size ${minimumCodeSize}; GIF requires a value between 2 and 8`);
230
+ }
231
+
232
+ const compressedData = readDataSubBlocks(byteReader);
233
+ const expectedPixelCount = width * height;
234
+ const colorTableEntryCount = strict && activeColorTable !== undefined ? activeColorTable.length / 3 : undefined;
235
+ let indexedPixels = decodeCompressedIndexStream(compressedData, minimumCodeSize, expectedPixelCount, {
236
+ strict,
237
+ colorTableEntryCount,
238
+ });
239
+ if (isInterlaced) {
240
+ indexedPixels = deinterlaceIndexedPixels(indexedPixels, width, height);
241
+ }
242
+
243
+ return {
244
+ type: 'image',
245
+ graphicControlExtension: graphicControlExtension === undefined ? undefined : {...graphicControlExtension},
246
+ left,
247
+ top,
248
+ width,
249
+ height,
250
+ isInterlaced,
251
+ colorTable,
252
+ minimumCodeSize,
253
+ pixels: indexedPixels,
254
+ };
255
+ }
256
+
257
+ function readImageDescriptor(byteReader, {logicalScreenWidth, logicalScreenHeight, strict}) {
258
+ const left = byteReader.readUnsignedLittleEndian16();
259
+ const top = byteReader.readUnsignedLittleEndian16();
260
+ const width = byteReader.readUnsignedLittleEndian16();
261
+ const height = byteReader.readUnsignedLittleEndian16();
262
+
263
+ if (strict && (width === 0 || height === 0)) {
264
+ throw new Error('Image block dimensions must be non-zero in strict mode');
265
+ }
266
+
267
+ const packedImageField = byteReader.readByte();
268
+ const hasLocalColorTable = (packedImageField & 0b1000_0000) !== 0;
269
+ const isInterlaced = (packedImageField & 0b0100_0000) !== 0;
270
+ const reservedBits = (packedImageField >> 3) & 0b11;
271
+ if (strict && reservedBits !== 0) {
272
+ throw new Error('Image Descriptor reserved bits must be zero');
273
+ }
274
+
275
+ if (left + width > logicalScreenWidth || top + height > logicalScreenHeight) {
276
+ throw new Error('Image block extends beyond the logical screen bounds');
277
+ }
278
+
279
+ const colorTableSizeField = packedImageField & 0b0000_0111;
280
+
281
+ requirePixelCountWithinLimit(width, height, defaultMaximumPixelCount, 'image block');
282
+
283
+ return {
284
+ left,
285
+ top,
286
+ width,
287
+ height,
288
+ hasLocalColorTable,
289
+ isInterlaced,
290
+ colorTableSizeField,
291
+ };
292
+ }
293
+
294
+ function validateTransparentColorIndex(graphicControlExtension, activeColorTable, {strict}) {
295
+ if (
296
+ strict
297
+ && graphicControlExtension?.transparentColorIndex !== undefined
298
+ && activeColorTable !== undefined
299
+ && graphicControlExtension.transparentColorIndex >= activeColorTable.length / 3
300
+ ) {
301
+ throw new Error('Transparent color index must be inside the active color table in strict mode');
302
+ }
303
+ }
304
+
305
+ function readGraphicControlExtension(byteReader, {strict}) {
306
+ const blockSize = byteReader.readByte();
307
+ if (blockSize !== 4) {
308
+ throw new Error(`Invalid Graphic Control Extension block size ${blockSize}; expected 4`);
309
+ }
310
+
311
+ const packedField = byteReader.readByte();
312
+ const reservedBits = packedField >> 5;
313
+ if (reservedBits !== 0 && strict) {
314
+ throw new Error('Graphic Control Extension reserved bits must be zero');
315
+ }
316
+
317
+ const disposalMethodField = (packedField >> 2) & 0b111;
318
+ if (strict && disposalMethodField >= disposalMethodNames.length) {
319
+ throw new Error('Disposal methods 4-7 are reserved in the GIF89a specification');
320
+ }
321
+
322
+ // The GIF user-input flag is intentionally ignored because interactive decoder prompts are obsolete and not useful in JS encode/decode workflows.
323
+ const isTransparencyFlag = (packedField & 0b1) !== 0;
324
+ const delayInHundredthsOfASecond = byteReader.readUnsignedLittleEndian16();
325
+ const transparentColorIndex = byteReader.readByte();
326
+ const blockTerminator = byteReader.readByte();
327
+ if (blockTerminator !== 0x00) {
328
+ throw new Error('Graphic Control Extension is missing its block terminator');
329
+ }
330
+
331
+ return {
332
+ disposalMethod: disposalMethodNames[disposalMethodField] ?? 'unspecified',
333
+ delay: delayInHundredthsOfASecond / 100,
334
+ transparentColorIndex: isTransparencyFlag ? transparentColorIndex : undefined,
335
+ };
336
+ }
337
+
338
+ function readAppExtension(byteReader, {strict}) {
339
+ const blockSize = byteReader.readByte();
340
+ if (blockSize !== 11) {
341
+ throw new Error(`Invalid Application Extension block size ${blockSize}; expected 11`);
342
+ }
343
+
344
+ const identifier = byteReader.readAsciiString(8);
345
+
346
+ if (strict && /[^\u{20}-\u{7E}]/v.test(identifier)) {
347
+ throw new Error('Application Identifier must use printable ASCII characters');
348
+ }
349
+
350
+ const authenticationCode = byteReader.readBytes(3);
351
+ const data = readDataSubBlocks(byteReader);
352
+
353
+ return {
354
+ type: 'applicationExtension',
355
+ identifier,
356
+ authenticationCode,
357
+ data,
358
+ };
359
+ }
360
+
361
+ function isGIF89aExtensionLabel(extensionLabel) {
362
+ return [
363
+ graphicControlExtensionLabel,
364
+ commentExtensionLabel,
365
+ plainTextExtensionLabel,
366
+ appExtensionLabel,
367
+ ].includes(extensionLabel);
368
+ }
369
+
370
+ function readUnknownExtension(byteReader, extensionLabel) {
371
+ const fixedBlockSize = byteReader.readByte();
372
+ const fixedData = byteReader.readBytes(fixedBlockSize);
373
+ if (fixedBlockSize === 0) {
374
+ return {
375
+ type: 'unknownExtension',
376
+ extensionLabel,
377
+ fixedData,
378
+ data: new Uint8Array(),
379
+ };
380
+ }
381
+
382
+ const data = readDataSubBlocks(byteReader);
383
+ return {
384
+ type: 'unknownExtension',
385
+ extensionLabel,
386
+ fixedData,
387
+ data,
388
+ };
389
+ }
390
+
391
+ function readGIF87aUnknownExtension(byteReader, extensionLabel) {
392
+ return {
393
+ type: 'unknownExtension',
394
+ extensionLabel,
395
+ fixedData: new Uint8Array(),
396
+ data: readDataSubBlocks(byteReader),
397
+ };
398
+ }
399
+
400
+ function isGraphicRenderingExtensionLabel(extensionLabel) {
401
+ // Unknown labels still delimit Graphic Control Extension scope according to the GIF89a label ranges.
402
+ return extensionLabel <= 0x7F && extensionLabel !== trailerByte;
403
+ }
404
+
405
+ function readDataSubBlocks(byteReader) {
406
+ let concatenatedData = new Uint8Array(1024);
407
+ let totalLength = 0;
408
+ let subBlockCount = 0;
409
+
410
+ while (true) {
411
+ const blockSize = byteReader.readByte();
412
+ if (blockSize === 0) {
413
+ break;
414
+ }
415
+
416
+ subBlockCount += 1;
417
+ requireCountWithinLimit(subBlockCount, defaultMaximumDataSubBlockCount, 'data sub-block count');
418
+
419
+ const nextTotalLength = totalLength + blockSize;
420
+ requireByteLengthWithinLimit(nextTotalLength, defaultMaximumDataPayloadByteLength, 'data payload');
421
+ if (nextTotalLength > concatenatedData.length) {
422
+ const nextCapacity = Math.min(defaultMaximumDataPayloadByteLength, Math.max(nextTotalLength, concatenatedData.length * 2));
423
+ const nextConcatenatedData = new Uint8Array(nextCapacity);
424
+ nextConcatenatedData.set(uint8ArraySubarray.call(concatenatedData, 0, totalLength));
425
+ concatenatedData = nextConcatenatedData;
426
+ }
427
+
428
+ byteReader.readBytesInto(concatenatedData, totalLength, blockSize);
429
+ totalLength = nextTotalLength;
430
+ }
431
+
432
+ return concatenatedData.slice(0, totalLength);
433
+ }
434
+
435
+ function readColorTable(byteReader, sizeField) {
436
+ const entryCount = 1 << (sizeField + 1);
437
+ const byteLength = entryCount * 3;
438
+ return byteReader.readBytes(byteLength);
439
+ }
440
+
441
+ function deinterlaceIndexedPixels(indexedPixels, width, height) {
442
+ const deinterlacedPixels = new Uint8Array(indexedPixels.length);
443
+ let sourceOffset = 0;
444
+ for (const [startRow, rowStep] of [[0, 8], [4, 8], [2, 4], [1, 2]]) {
445
+ for (let row = startRow; row < height; row += rowStep) {
446
+ const destinationOffset = row * width;
447
+ deinterlacedPixels.set(uint8ArraySubarray.call(indexedPixels, sourceOffset, sourceOffset + width), destinationOffset);
448
+ sourceOffset += width;
449
+ }
450
+ }
451
+
452
+ return deinterlacedPixels;
453
+ }