Skip to content

Commit 50ebe2b

Browse files
committed
check encoder and decoder options
1 parent 6e638c5 commit 50ebe2b

7 files changed

Lines changed: 131 additions & 22 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,21 @@
2222
the same, except for rare coefficients the new transforms round differently (2 of 108 test encodes, both the same
2323
size).
2424

25+
### Fixes
26+
27+
- Invalid options throw a `RangeError` naming them before any work, instead of failing inside an encoder or being
28+
ignored:
29+
- `encodeAVIF()`: `effort` other than 0, 1 or 2 failed with a `TypeError` deep in the encoder, `quality` and
30+
`alphaQuality` outside 0-100 were accepted, and an unknown `subsampling` silently meant 4:4:4 (now a
31+
`TypeError`).
32+
- `encodeWebP()`, `Animation.encodeWebP()` and `Animation.webpEncoder()` check `effort` for lossless images too
33+
(which don't use it).
34+
- `NaN` is refused as GIF `quality` and APNG `compression` (out of range values are still clamped, like PNG's), as
35+
`dominantColor()`'s `threshold` and as `renderSVG()`'s size (which threw about the image's width); negative font
36+
sizes in `renderText()` (which rendered an empty image); `maxPixels` that isn't positive in `Image.decode()` and
37+
`Animation.decode()` (which reported every image as too large); fractional loop counts in `new Animation()`.
38+
- `Animation.decode()` reports invalid `threads` and `maxPixels` as such, not as damaged image data.
39+
2540
## 3.13.2
2641

2742
### Fixes

‎src/animation-encoder.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Frame, FrameOptions } from './frame.ts';
66
import { Optimizer, Compositor, InputFrame, OptimizeOptions, OptimizedFrame, coverCanvas, fillUnchanged } from './util/optimize.ts';
77
import { compressAsync, decompressAsync } from './codecs/zlib.ts';
88
import { Metadata, resolveMetadata, pngText, exifData, gifComments } from './metadata.ts';
9-
import { finite } from './util/options.ts';
9+
import { finite, compressionLevel } from './util/options.ts';
1010

1111
// (ImageScript, like GIF, counts repetitions: -1 forever, 0 play once; APNG and WebP count plays, 0 forever)
1212
const toPlays = (loopCount: number): number => loopCount < 0 ? 0 : loopCount + 1;
@@ -151,7 +151,8 @@ export class AnimationEncoder {
151151
}
152152

153153
/** @internal */
154-
static __gif__(width: number, height: number, { loopCount = -1, metadata = {}, optimize = true, quality = 95 }: GIFAnimationEncoderOptions): AnimationEncoder {
154+
static __gif__(width: number, height: number, { loopCount = -1, metadata = {}, optimize = true, quality: qualityOption = 95 }: GIFAnimationEncoderOptions): AnimationEncoder {
155+
const quality = finite(qualityOption, 'quality');
155156
canvas(width, height, loopCount);
156157
const comments = gifComments(resolveMetadata(metadata));
157158
// (the first frame is encoded last: it needs a transparent index if any frame restores the background)
@@ -177,7 +178,8 @@ export class AnimationEncoder {
177178
}
178179

179180
/** @internal */
180-
static __apng__(width: number, height: number, { loopCount = -1, metadata = {}, optimize = true, compression = 4 }: APNGAnimationEncoderOptions): AnimationEncoder {
181+
static __apng__(width: number, height: number, { loopCount = -1, metadata = {}, optimize = true, compression: compressionOption = 4 }: APNGAnimationEncoderOptions): AnimationEncoder {
182+
const compression = compressionLevel(compressionOption);
181183
canvas(width, height, loopCount);
182184
const text = pngText(resolveMetadata(metadata));
183185
// the frames are kept compressed (quickly): the pixel format, and whether opaque animations are stored without

‎src/animation.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Frame } from './frame.ts';
1010
import { ImageType } from './image-type.ts';
1111
import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts';
1212
import { Metadata, resolveMetadata, pngText, exifData, gifComments } from './metadata.ts';
13-
import { isOptions, finite } from './util/options.ts';
13+
import { isOptions, finite, compressionLevel } from './util/options.ts';
1414
import { MAX_PIXELS, decoding } from './util/limits.ts';
1515
import { AnimationEncoder, WebPAnimationEncoderOptions, GIFAnimationEncoderOptions, APNGAnimationEncoderOptions } from './animation-encoder.ts';
1616

@@ -123,8 +123,8 @@ export class Animation extends Array<Frame> {
123123
if (!(this[i] instanceof Frame))
124124
throw new TypeError(`Frame ${i} is not an instance of Frame`);
125125

126-
if (finite(loopCount, 'loop count') < -1)
127-
throw new RangeError('Invalid loop count');
126+
if (finite(loopCount, 'loop count') < -1 || !Number.isInteger(Number(loopCount)))
127+
throw new RangeError(`Invalid loop count: ${loopCount}`);
128128

129129
this.loopCount = loopCount;
130130
}
@@ -263,7 +263,7 @@ export class Animation extends Array<Frame> {
263263
// gifs store centiseconds; rounding keeps e.g. 16 ms (60 fps) from becoming 10 ms
264264
const gifFrames = frames.map(({ duration, ...f }) => ({ ...f, delay: Math.round(duration / 10) }));
265265

266-
return gifCodec.encode({ width: this.width, height: this.height, loopCount: this.loopCount, frames: gifFrames }, quality, gifComments(resolveMetadata(metadata)));
266+
return gifCodec.encode({ width: this.width, height: this.height, loopCount: this.loopCount, frames: gifFrames }, finite(quality, 'quality'), gifComments(resolveMetadata(metadata)));
267267
}
268268

269269
/**
@@ -290,7 +290,7 @@ export class Animation extends Array<Frame> {
290290
const frames = optimize
291291
? this.__optimized__({ replace: true, even: false, alpha: frames => png.alphaCost(frames.map(f => f.pixels)) }).map(f => ({ ...f, dispose: f.dispose ? 1 : 0 }))
292292
: this.__apngFrames__().map(({ blend: _, ...f }) => f); // blended, the encoder's default
293-
return png.encodeAnimation(frames, this.width, this.height, toPlays(this.loopCount), compression, pngText(resolveMetadata(metadata)));
293+
return png.encodeAnimation(frames, this.width, this.height, toPlays(this.loopCount), compressionLevel(compression), pngText(resolveMetadata(metadata)));
294294
}
295295

296296
/**
@@ -412,6 +412,12 @@ export class Animation extends Array<Frame> {
412412
*/
413413
static decode(input: BinaryData, onlyExtractFirstFrame?: boolean): Promise<Animation>;
414414
static async decode(input: BinaryData, onlyFirstFrameOrOptions: boolean | AnimationDecodeOptions = false): Promise<Animation> {
415+
// (checked outside of decoding(), which reports RangeErrors as damaged data)
416+
if (isOptions(onlyFirstFrameOrOptions)) {
417+
const { maxPixels = MAX_PIXELS, threads = 1 } = onlyFirstFrameOrOptions;
418+
if (!Number.isInteger(threads) || threads < 1) throw new RangeError(`threads must be a positive integer, not ${threads}`);
419+
if (!(Number(maxPixels) > 0)) throw new RangeError(`maxPixels must be a positive number, not ${maxPixels}`);
420+
}
415421
return decoding(() => Animation.__decode__(input, onlyFirstFrameOrOptions));
416422
}
417423

@@ -421,7 +427,6 @@ export class Animation extends Array<Frame> {
421427
*/
422428
private static async __decode__(input: BinaryData, onlyFirstFrameOrOptions: boolean | AnimationDecodeOptions): Promise<Animation> {
423429
const { onlyFirstFrame: onlyExtractFirstFrame = false, maxPixels = MAX_PIXELS, applyColorProfile = true, threads = Math.max(1, Math.min(availableThreads(), 8)) } = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions : { onlyFirstFrame: onlyFirstFrameOrOptions };
424-
if (!Number.isInteger(threads) || threads < 1) throw new RangeError(`threads must be a positive integer, not ${threads}`);
425430
const profile = (frames: Frame[], icc: Uint8Array | undefined, space?: jxl.ColorSpace): Frame[] => {
426431
const transform = !applyColorProfile ? null : icc ? profileTransform(icc) : space ? colorSpaceTransform(space.xy, space.curve) : null;
427432
if (transform) for (const frame of frames) transform(frame.bitmap);

‎src/codecs/webp.ts‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { encodeVP8, encodeAlpha } from './vp8-enc.ts';
1010
import { rgbaToYUV } from './vp8-enc-dsp.ts';
1111
import { orientation as exifOrientation } from './exif.ts';
1212
import { MAX_PIXELS, checkPixels } from '../util/limits.ts';
13-
import { finite } from '../util/options.ts';
13+
import { effort as checkEffort, quality as checkQuality } from '../util/options.ts';
1414
import { startThreads } from '../util/threads.ts';
1515

1616
const fourCC = (s: string): number => s.charCodeAt(0) | s.charCodeAt(1) << 8 | s.charCodeAt(2) << 16 | s.charCodeAt(3) << 24;
@@ -371,11 +371,10 @@ export interface LossyOptions {
371371
* @returns undefined without a quality: lossless
372372
*/
373373
export function lossyOptions(quality: number | undefined, effort: number, sharp: boolean, alphaQuality: number): LossyOptions | undefined {
374+
// (checked for lossless images too, which don't use it)
375+
checkEffort(effort, 6);
374376
if (quality === undefined) return undefined;
375-
const q = finite(quality, 'quality'), a = finite(alphaQuality, 'alphaQuality');
376-
if (q < 0 || q > 100) throw new RangeError(`quality must be 0-100, not ${q}`);
377-
if (a < 0 || a > 100) throw new RangeError(`alphaQuality must be 0-100, not ${a}`);
378-
if (!Number.isInteger(effort) || effort < 0 || effort > 6) throw new RangeError(`effort must be an integer 0-6, not ${effort}`);
377+
const q = checkQuality(quality), a = checkQuality(alphaQuality, 'alphaQuality');
379378
return { quality: q, method: effort, sharp, alphaQuality: Math.round(a) };
380379
}
381380

‎src/image.ts‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { encode as utf8 } from './util/utf8.ts';
3232
import { resample, BOX, TRIANGLE, CUBIC } from './util/resample.ts';
3333
import { rotateBilinear } from './util/rotate.ts';
3434
import { gaussianBlur, boxBlurRGBA, premultiply, unpremultiply } from './util/blur.ts';
35-
import { isOptions, finite, color as checkColor } from './util/options.ts';
35+
import { isOptions, finite, color as checkColor, quality as checkQuality, effort as checkEffort, compressionLevel } from './util/options.ts';
3636
import { MAX_PIXELS, decoding } from './util/limits.ts';
3737

3838
/** A color as a 32-bit RGBA integer (`0xRRGGBBAA`) */
@@ -1797,7 +1797,7 @@ export class Image {
17971797
dominantColor(ignoreBlackOrOptions: boolean | DominantColorOptions = true, ignoreWhiteArgument = true, thresholdArgument = 0xf): Color {
17981798
const { ignoreBlack = true, ignoreWhite = true, threshold: bwThreshold = 0xf } = typeof ignoreBlackOrOptions === 'object' && ignoreBlackOrOptions !== null
17991799
? ignoreBlackOrOptions : { ignoreBlack: ignoreBlackOrOptions, ignoreWhite: ignoreWhiteArgument, threshold: thresholdArgument };
1800-
const threshold = Math.min(64, Math.max(0, bwThreshold)) * 4;
1800+
const threshold = Math.min(64, Math.max(0, finite(bwThreshold, 'threshold'))) * 4;
18011801
const pixels = this.__bytes__();
18021802

18031803
const color = quantize.dominantColor(pixels, (r, g, b) =>
@@ -2137,8 +2137,7 @@ export class Image {
21372137
({ compression = 4, metadata = {} } = compressionOrOptions);
21382138
else metadata = compressionOrOptions as Metadata;
21392139

2140-
const level = Math.min(9, Math.max(0, ~~finite(compression, 'compression level')));
2141-
return png.encodeRGBA(this.__bytes__(), this.width, this.height, level, pngText(resolveMetadata(metadata)));
2140+
return png.encodeRGBA(this.__bytes__(), this.width, this.height, compressionLevel(compression), pngText(resolveMetadata(metadata)));
21422141
}
21432142

21442143
/**
@@ -2218,9 +2217,11 @@ export class Image {
22182217
* @param options See {@link AVIFEncodeOptions}
22192218
*/
22202219
async encodeAVIF({ quality = 60, alphaQuality = 100, subsampling = '4:4:4', effort = 1, threads = defaultThreads(), metadata = {} }: AVIFEncodeOptions = {}): Promise<Uint8Array> {
2220+
checkEffort(effort, 2);
22212221
checkThreads(threads);
2222+
if (subsampling !== '4:4:4' && subsampling !== '4:2:0') throw new TypeError(`subsampling must be '4:4:4' or '4:2:0', not ${String(subsampling)}`);
22222223
const exif = exifData(resolveMetadata(metadata));
2223-
return avifEnc.encode(this.__bytes__(), this.width, this.height, { quality: finite(quality, 'quality'), alphaQuality: finite(alphaQuality, 'alphaQuality'), subsampling, effort, exif, threads });
2224+
return avifEnc.encode(this.__bytes__(), this.width, this.height, { quality: checkQuality(quality), alphaQuality: checkQuality(alphaQuality, 'alphaQuality'), subsampling, effort, exif, threads });
22242225
}
22252226

22262227
/**
@@ -2234,12 +2235,11 @@ export class Image {
22342235
* @param options See {@link JXLEncodeOptions}
22352236
*/
22362237
async encodeJXL({ quality, effort = 1, threads = defaultThreads(), metadata = {} }: JXLEncodeOptions = {}): Promise<Uint8Array> {
2237-
if (!Number.isInteger(effort) || effort < 0 || effort > 2) throw new RangeError(`effort must be an integer 0-2, not ${effort}`);
2238+
checkEffort(effort, 2);
22382239
checkThreads(threads);
22392240
let distance = 0;
22402241
if (quality !== undefined) {
2241-
const q = finite(quality, 'quality');
2242-
if (q < 0 || q > 100) throw new RangeError(`quality must be 0-100, not ${q}`);
2242+
const q = checkQuality(quality);
22432243
// (cjxl's distance of a quality)
22442244
distance = q >= 100 ? 0 : q >= 30 ? 0.1 + (100 - q) * 0.09 : 53 / 3000 * q * q - 23 / 20 * q + 25;
22452245
}
@@ -2267,6 +2267,7 @@ export class Image {
22672267
static async decode(input: BinaryData, options: DecodeOptions = {}): Promise<Image> {
22682268
const { applyOrientation = true, applyColorProfile = true, maxPixels = MAX_PIXELS, toneMapping = 'reinhard', threads = defaultThreads() } = options;
22692269
checkThreads(threads);
2270+
if (!(Number(maxPixels) > 0)) throw new RangeError(`maxPixels must be a positive number, not ${maxPixels}`);
22702271
if (toneMapping !== 'reinhard' && toneMapping !== 'clamp') throw new TypeError(`toneMapping must be 'reinhard' or 'clamp', not ${String(toneMapping)}`);
22712272
const data = view(input);
22722273
const dataView = new DataView(data.buffer, data.byteOffset, data.byteLength);
@@ -2386,6 +2387,7 @@ export class Image {
23862387
if (![1, 2, 3].includes(mode))
23872388
throw new Error('Invalid SVG scaling mode');
23882389

2390+
finite(size, 'SVG size');
23892391
if (mode === Image.SVG_MODE_SCALE && size <= 0)
23902392
throw new RangeError('SVG scale must be > 0');
23912393
if (mode !== Image.SVG_MODE_SCALE && size < 1)
@@ -2451,6 +2453,7 @@ export class Image {
24512453
const { color = 0xffffffff, layout = new TextLayout(), strokeWidth = 0, strokeColor = 0x000000ff, hinting = true, features = {}, variations = {} } = options;
24522454
const axes = textRenderer.axisValues(variations);
24532455
size = finite(size, 'font size');
2456+
if (size < 0) throw new RangeError(`Invalid font size: ${size}`);
24542457
if (finite(strokeWidth, 'stroke width') < 0)
24552458
throw new RangeError('Invalid stroke width');
24562459
const fonts = Array.isArray(font) ? font : [font];

‎src/util/options.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,21 @@ export function finite(value: number, name: string): number {
1313
return number;
1414
}
1515

16+
/** A quality argument (0-100) of the encoders that take one, finite and in range */
17+
export function quality(value: number, name = 'quality'): number {
18+
const q = finite(value, name);
19+
if (q < 0 || q > 100) throw new RangeError(`${name} must be 0-100, not ${q}`);
20+
return q;
21+
}
22+
23+
/** An effort argument: an integer from 0 to max */
24+
export function effort(value: number, max: number): number {
25+
if (!Number.isInteger(value) || value < 0 || value > max) throw new RangeError(`effort must be an integer 0-${max}, not ${value}`);
26+
return value;
27+
}
28+
29+
/** A zlib compression level: finite, clamped to 0-9 and truncated (PNG's and APNG's) */
30+
export const compressionLevel = (value: number): number => Math.min(9, Math.max(0, ~~finite(value, 'compression level')));
31+
1632
/** A color argument: a finite number (as RGBA, 0xrrggbbaa) */
1733
export const color = (value: number, name = 'color'): number => finite(value, name);

‎tests/option-checks.js‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
const {Image, Frame, Animation} = require('..');
2+
3+
const panic = message => {
4+
console.error(message);
5+
process.exit(1);
6+
};
7+
8+
// invalid options throw a RangeError (or TypeError) naming them, before any work, instead of failing somewhere inside
9+
// an encoder or being ignored
10+
const rejects = async (name, fn, Type, pattern) => {
11+
try {
12+
await fn();
13+
} catch (error) {
14+
if (!(error instanceof Type) || !pattern.test(error.message)) panic(`${name}: wrong error ${error?.constructor?.name}: ${error?.message}`);
15+
return;
16+
}
17+
panic(`${name}: accepted`);
18+
};
19+
20+
(async () => {
21+
const image = new Image(8, 8).fill(0x336699ff);
22+
const animation = () => new Animation([new Frame(8, 8, {duration: 100}).fill(0xff0000ff), new Frame(8, 8, {duration: 100}).fill(0x00ff00ff)]);
23+
const encoded = async (encoder) => {
24+
await encoder.add(new Frame(8, 8).fill(0xff0000ff));
25+
return encoder.finish();
26+
};
27+
28+
for (const effort of [-1, 3, 1.5, NaN, '1', null]) {
29+
await rejects(`encodeAVIF effort ${effort}`, () => image.encodeAVIF({effort}), RangeError, /effort must be an integer 0-2/);
30+
await rejects(`encodeJXL effort ${effort}`, () => image.encodeJXL({effort}), RangeError, /effort must be an integer 0-2/);
31+
}
32+
for (const effort of [-1, 7, 1.5, NaN]) {
33+
// (lossless WebP doesn't use it, but checks it)
34+
await rejects(`encodeWebP lossless effort ${effort}`, () => image.encodeWebP({effort}), RangeError, /effort must be an integer 0-6/);
35+
await rejects(`encodeWebP effort ${effort}`, () => image.encodeWebP({quality: 80, effort}), RangeError, /effort must be an integer 0-6/);
36+
await rejects(`Animation.encodeWebP effort ${effort}`, () => animation().encodeWebP({effort}), RangeError, /effort must be an integer 0-6/);
37+
await rejects(`webpEncoder effort ${effort}`, () => encoded(Animation.webpEncoder(8, 8, {effort})), RangeError, /effort must be an integer 0-6/);
38+
}
39+
for (const quality of [-1, 101, NaN]) {
40+
await rejects(`encodeAVIF quality ${quality}`, () => image.encodeAVIF({quality}), RangeError, /quality/);
41+
await rejects(`encodeAVIF alphaQuality ${quality}`, () => image.encodeAVIF({alphaQuality: quality}), RangeError, /alphaQuality/);
42+
}
43+
for (const subsampling of ['4:2:2', '', null])
44+
await rejects(`encodeAVIF subsampling ${subsampling}`, () => image.encodeAVIF({subsampling}), TypeError, /subsampling must be/);
45+
46+
// GIF quality and APNG compression are clamped (like PNG's), but have to be numbers
47+
await rejects('encodeGIF quality NaN', () => animation().encodeGIF({quality: NaN}), RangeError, /quality/);
48+
await rejects('gifEncoder quality NaN', () => encoded(Animation.gifEncoder(8, 8, {quality: NaN})), RangeError, /quality/);
49+
await rejects('encodeAPNG compression NaN', () => animation().encodeAPNG({compression: NaN}), RangeError, /compression level/);
50+
await rejects('apngEncoder compression NaN', () => encoded(Animation.apngEncoder(8, 8, {compression: NaN})), RangeError, /compression level/);
51+
if (!(await animation().encodeGIF({quality: 1000})).length || !(await animation().encodeAPNG({compression: 12})).length) panic('clamped values rejected');
52+
53+
await rejects('Animation loopCount 1.5', () => new Animation([new Frame(8, 8)], {loopCount: 1.5}), RangeError, /loop count/);
54+
await rejects('dominantColor threshold NaN', () => image.dominantColor({threshold: NaN}), RangeError, /threshold/);
55+
await rejects('renderSVG size NaN', () => Image.renderSVG('<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"/>', {size: NaN}), RangeError, /SVG size/);
56+
await rejects('renderText size -1', () => Image.renderText(new Uint8Array(0), -1, 'x'), RangeError, /font size/);
57+
const png = await image.encode();
58+
for (const maxPixels of [NaN, -1, 0]) {
59+
await rejects(`Image.decode maxPixels ${maxPixels}`, () => Image.decode(png, {maxPixels}), RangeError, /maxPixels/);
60+
await rejects(`Animation.decode maxPixels ${maxPixels}`, () => Animation.decode(png, {maxPixels}), RangeError, /maxPixels/);
61+
}
62+
await rejects('Animation.decode threads 0', () => Animation.decode(png, {threads: 0}), RangeError, /threads must be/);
63+
64+
// valid values keep working
65+
for (const effort of [0, 1, 2]) await image.encodeAVIF({effort});
66+
for (const subsampling of ['4:4:4', '4:2:0']) await image.encodeAVIF({quality: 50, subsampling});
67+
await image.encodeWebP({effort: 6});
68+
await Image.decode(png, {maxPixels: Infinity});
69+
})();

0 commit comments

Comments
 (0)