From d2b13053e2739c251bd9528b97b679f5b927e0e6 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 16:06:33 +0000 Subject: [PATCH 001/188] remove the RESIZE_*, SVG_MODE_* and DISPOSAL_* constants --- CHANGELOG.md | 6 +++ scripts/docs.js | 2 +- src/animation.ts | 11 ++--- src/frame.ts | 59 +++++------------------ src/image.ts | 112 +++++++++---------------------------------- src/index.ts | 2 +- tests/avif-encode.js | 2 +- tests/edge/smoke.mjs | 2 +- tests/endian.js | 2 +- tests/gif.js | 6 +-- tests/jxl-encode.js | 2 +- tests/misc.js | 5 -- tests/options.js | 12 ++--- tests/regressions.js | 72 +++++++++++----------------- tests/resize.js | 10 ++-- tests/svg.js | 6 +-- tests/util/resvg.js | 4 +- 17 files changed, 101 insertions(+), 214 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44a1c441..b4c84a44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 4.0.0 + +### Breaking changes + +- **The deprecated resize, SVG and frame disposal constants are removed**: `Image.RESIZE_NEAREST_NEIGHBOR`, `Image.RESIZE_BILINEAR`, `Image.RESIZE_BICUBIC`, `Image.RESIZE_AREA`, `Image.SVG_MODE_SCALE`, `Image.SVG_MODE_WIDTH`, `Image.SVG_MODE_HEIGHT` and `Frame.DISPOSAL_*` are gone: use `ResizeModes`, `SVGScaleModes` and `FrameDisposalModes` instead. `ResizeModes.AUTO` (and `Image.RESIZE_AUTO`) is removed too: pass only the width or the height, `image.resize({ width: 256 })`. The old values are no longer accepted either: `'RESIZE_BILINEAR'`, the numbers 1-3 for SVG scale modes and the numeric disposal ids 0-3. + ## 3.20.1 ### Performance diff --git a/scripts/docs.js b/scripts/docs.js index 73186fbe..8cb6530c 100644 --- a/scripts/docs.js +++ b/scripts/docs.js @@ -45,7 +45,7 @@ for (const page of pages) { html = html.replace(/()([^<]+?) - (v[^<]+)<\/a>/, '$1$2 $3'); html = html.replace(/(
[\s\S]*?

)(Class|Interface|Function|Type Alias|Variable|Enumeration|Namespace) /, '$1$2'); - // constants typed as their own literal (RESIZE_AREA: "RESIZE_AREA" = 'RESIZE_AREA') show the value once + // constants typed as their own literal (ResizeModes.AREA: "area" = 'area') show the value once html = html.replace(/((?:"([^&<]*)"|(-?[\d.]+))<\/span>) = (?:'([^&<]*)'|(-?[\d.]+))<\/span>/g, (all, type, typeString, typeNumber, valueString, valueNumber) => (typeString ?? typeNumber) === (valueString ?? valueNumber) ? type : all); diff --git a/src/animation.ts b/src/animation.ts index 7ffde8fb..1fe680c8 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -7,7 +7,7 @@ import { availableThreads } from './util/threads.ts'; import { BinaryData, view } from './util/mem.ts'; import { GainMap } from './gainmap.ts'; import type { ToneMapping } from './codecs/hdr.ts'; -import { Image, ResizeMode, ResizeModes, ResizeOptions, ResizeToOptions, resizeArguments } from './image.ts'; +import { Image, ResizeMode, ResizeOptions, ResizeToOptions, resizeArguments } from './image.ts'; import { Frame, FrameDisposalModes } from './frame.ts'; import { ImageType } from './image-type.ts'; import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts'; @@ -248,12 +248,11 @@ export class Animation extends Array { */ resize(options: ResizeToOptions): this; resize(widthOrOptions: number | ResizeToOptions, heightArgument?: number, modeArgument?: ResizeMode | ResizeOptions): this { - // eslint-disable-next-line prefer-const - let [width, height, modeOrOptions] = resizeArguments(widthOrOptions, heightArgument, modeArgument); + const [givenWidth, givenHeight, modeOrOptions] = resizeArguments(widthOrOptions, heightArgument, modeArgument); const originalWidth = this.width, originalHeight = this.height; - if (width === ResizeModes.AUTO && height === ResizeModes.AUTO) throw new Error('RESIZE_AUTO can only be used for either width or height, not for both'); - if (width === ResizeModes.AUTO) width = originalWidth / originalHeight * height; - if (height === ResizeModes.AUTO) height = originalHeight / originalWidth * width; + // a side left out follows the other one, keeping the aspect ratio + const width = givenWidth ?? originalWidth / originalHeight * (givenHeight as number); + const height = givenHeight ?? originalHeight / originalWidth * width; const sx = width / originalWidth, sy = height / originalHeight; for (const frame of this) { diff --git a/src/frame.ts b/src/frame.ts index 9339437a..baa301f9 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -3,14 +3,8 @@ import { isOptions, finite } from './util/options.ts'; /** Names of the frame disposal modes, see {@link Frame.disposalMode} */ export type FrameDisposalModeName = 'any' | 'keep' | 'previous' | 'background'; -/** - * Ids of the frame disposal modes, as GIFs store them (0 any, 1 keep, 2 previous, 3 background) - * @deprecated Use the names ({@link FrameDisposalModeName}: `'any'`, `'keep'`, `'previous'`, `'background'`), the ids will - * be removed in 4.0 - */ -export type FrameDisposalModeId = 0 | 1 | 2 | 3; -// (the ids inside the library, without the deprecation shown to its own code) +// the ids the library stores the modes as (0 any, 1 keep, 2 previous, 3 background, like GIFs) type DisposalId = 0 | 1 | 2 | 3; const DISPOSAL_MODES: readonly FrameDisposalModeName[] = ['any', 'keep', 'previous', 'background']; @@ -41,7 +35,7 @@ export interface FrameOptions { /** The frame's offset on the y-axis. Default: `0` */ yOffset?: number; /** The frame's disposal mode, see {@link Frame.disposalMode}. Default: `'keep'` */ - disposalMode?: FrameDisposalModeName | FrameDisposalModeId; + disposalMode?: FrameDisposalModeName; } /** @@ -64,37 +58,13 @@ export class Frame extends Image { */ private __disposalMode__: DisposalId = 1; - /** - * @deprecated Use {@link FrameDisposalModes.ANY}, `Frame.DISPOSAL_*` will be removed in 4.0 - * @group Deprecated - */ - static readonly DISPOSAL_ANY: typeof FrameDisposalModes.ANY = FrameDisposalModes.ANY; - - /** - * @deprecated Use {@link FrameDisposalModes.KEEP}, `Frame.DISPOSAL_*` will be removed in 4.0 - * @group Deprecated - */ - static readonly DISPOSAL_KEEP: typeof FrameDisposalModes.KEEP = FrameDisposalModes.KEEP; - - /** - * @deprecated Use {@link FrameDisposalModes.PREVIOUS}, `Frame.DISPOSAL_*` will be removed in 4.0 - * @group Deprecated - */ - static readonly DISPOSAL_PREVIOUS: typeof FrameDisposalModes.PREVIOUS = FrameDisposalModes.PREVIOUS; - - /** - * @deprecated Use {@link FrameDisposalModes.BACKGROUND}, `Frame.DISPOSAL_*` will be removed in 4.0 - * @group Deprecated - */ - static readonly DISPOSAL_BACKGROUND: typeof FrameDisposalModes.BACKGROUND = FrameDisposalModes.BACKGROUND; - /** * @internal * @ignore */ - static __convert_disposal_mode__(mode: FrameDisposalModeName | FrameDisposalModeId): DisposalId { - const id = typeof mode === 'string' ? DISPOSAL_MODES.indexOf(mode) : mode; - if (!(id >= 0 && id <= 3)) + static __convert_disposal_mode__(mode: FrameDisposalModeName): DisposalId { + const id = DISPOSAL_MODES.indexOf(mode); + if (id < 0) throw new RangeError('Invalid disposal mode'); return id as DisposalId; @@ -117,8 +87,8 @@ export class Frame extends Image { * @param disposalMode The frame's disposal mode * @deprecated Pass {@link FrameOptions} instead, the positional arguments will be removed in 4.0 */ - constructor(width: number, height: number, duration?: number, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName | FrameDisposalModeId); - constructor(width: number, height: number, durationOrOptions?: number | FrameOptions, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName | FrameDisposalModeId) { + constructor(width: number, height: number, duration?: number, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName); + constructor(width: number, height: number, durationOrOptions?: number | FrameOptions, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName) { const { duration = 100, xOffset: x = 0, yOffset: y = 0, disposalMode: disposal = FrameDisposalModes.KEEP } = isOptions(durationOrOptions) ? durationOrOptions : { duration: durationOrOptions, xOffset, yOffset, disposalMode }; if (finite(duration, 'frame duration') < 0) @@ -133,10 +103,7 @@ export class Frame extends Image { this.disposalMode = disposal; } - /** - * The frame's disposal mode (`'any'`, `'keep'`, `'previous'` or `'background'`). Setting it by id (0-3) is deprecated, - * the ids will be removed in 4.0 - */ + /** The frame's disposal mode (`'any'`, `'keep'`, `'previous'` or `'background'`) */ get disposalMode(): FrameDisposalModeName { return DISPOSAL_MODES[this.__disposalMode__]; } @@ -152,9 +119,9 @@ export class Frame extends Image { /** * Sets the disposal mode - * @param disposalMode The mode's name (`'any'`, `'keep'`, `'previous'`, `'background'`) or, deprecated, its id (0-3) + * @param disposalMode The mode's name (`'any'`, `'keep'`, `'previous'`, `'background'`) */ - set disposalMode(disposalMode: FrameDisposalModeName | FrameDisposalModeId) { + set disposalMode(disposalMode: FrameDisposalModeName) { this.__disposalMode__ = Frame.__convert_disposal_mode__(disposalMode); } @@ -178,8 +145,8 @@ export class Frame extends Image { * @param disposalMode The frame's disposal mode * @deprecated Pass {@link FrameOptions} instead, the positional arguments will be removed in 4.0 */ - static from(image: Image, duration?: number, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName | FrameDisposalModeId): Frame; - static from(image: Image, durationOrOptions?: number | FrameOptions, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName | FrameDisposalModeId): Frame { + static from(image: Image, duration?: number, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName): Frame; + static from(image: Image, durationOrOptions?: number | FrameOptions, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName): Frame { if (!(image instanceof Image)) throw new TypeError('Invalid image passed'); @@ -262,7 +229,7 @@ export class Frame extends Image { const originalWidth = this.width; const originalHeight = this.height; - super.resize(width, height, modeOrOptions as ResizeMode); + super.resize(...this.__resized_size__(width, height), modeOrOptions as ResizeMode); this.xOffset = Math.round(this.xOffset * this.width / originalWidth); this.yOffset = Math.round(this.yOffset * this.height / originalHeight); diff --git a/src/image.ts b/src/image.ts index 36f386ad..2b97e987 100644 --- a/src/image.ts +++ b/src/image.ts @@ -47,19 +47,11 @@ export type Color = number; /** Called with a pixel's (0-based) coordinates, returns its color */ export type ColorFunction = (x: number, y: number) => Color; -/** - * Resampling filters for {@link Image.resize} and the other scaling functions, see {@link ResizeModes}. - * The upper case names (`'RESIZE_BILINEAR'`, the values of the deprecated `Image.RESIZE_*`) still work, they are - * removed in 4.0 - */ -export type ResizeMode = 'nearest-neighbor' | 'bilinear' | 'bicubic' | 'area' - | 'RESIZE_NEAREST_NEIGHBOR' | 'RESIZE_BILINEAR' | 'RESIZE_BICUBIC' | 'RESIZE_AREA'; +/** Resampling filters for {@link Image.resize} and the other scaling functions, see {@link ResizeModes} */ +export type ResizeMode = 'nearest-neighbor' | 'bilinear' | 'bicubic' | 'area'; -/** - * How {@link Image.renderSVG} scales, see {@link SVGScaleModes}. The numbers 1-3 (the values of the deprecated - * `Image.SVG_MODE_*`) still work, they are removed in 4.0 - */ -export type SVGScaleMode = 'scale' | 'width' | 'height' | 1 | 2 | 3; +/** How {@link Image.renderSVG} scales, see {@link SVGScaleModes} */ +export type SVGScaleMode = 'scale' | 'width' | 'height'; /** * The resampling filters of {@link Image.resize} and the other scaling functions: @@ -86,14 +78,6 @@ export class ResizeModes { */ static readonly AREA = 'area'; - /** - * Not a filter: passed as the width or the height, that side follows from the other one, keeping the image's - * aspect ratio - * @deprecated Pass only the width or the height instead, `image.resize({ width: 256 })`. `AUTO` is removed in 4.0 - * @group Deprecated - */ - static readonly AUTO = -1; - private constructor() {} } @@ -1074,37 +1058,6 @@ export class Image { return image; } - /** - * @deprecated Use {@link ResizeModes.NEAREST_NEIGHBOR}, `Image.RESIZE_*` will be removed in 4.0 - * @group Deprecated - */ - static readonly RESIZE_NEAREST_NEIGHBOR = 'RESIZE_NEAREST_NEIGHBOR'; - - /** - * @deprecated Use {@link ResizeModes.BILINEAR}, `Image.RESIZE_*` will be removed in 4.0 - * @group Deprecated - */ - static readonly RESIZE_BILINEAR = 'RESIZE_BILINEAR'; - - /** - * @deprecated Use {@link ResizeModes.BICUBIC}, `Image.RESIZE_*` will be removed in 4.0 - * @group Deprecated - */ - static readonly RESIZE_BICUBIC = 'RESIZE_BICUBIC'; - - /** - * @deprecated Use {@link ResizeModes.AREA}, `Image.RESIZE_*` will be removed in 4.0 - * @group Deprecated - */ - static readonly RESIZE_AREA = 'RESIZE_AREA'; - - /** - * @deprecated Pass only the width or the height instead, `image.resize({ width: 256 })`. `Image.RESIZE_*` will - * be removed in 4.0 - * @group Deprecated - */ - static readonly RESIZE_AUTO: typeof ResizeModes.AUTO = ResizeModes.AUTO; - /** * Resizes the image by the given factor * @param factor The factor to resize the image with @@ -1163,7 +1116,7 @@ export class Image { resize(widthOrOptions: number | ResizeToOptions, heightArgument?: number, modeArgument?: ResizeMode | ResizeOptions): this { const [width, height, modeOrOptions] = resizeArguments(widthOrOptions, heightArgument, modeArgument); const { mode, fast } = resizeOptions(modeOrOptions); - const image = this.__resize__(width, height, mode, fast); + const image = this.__resize__(...this.__resized_size__(width, height), mode, fast); this.__gain_map__?.__rescale__(this.width, this.height, image.width, image.height); return this.__apply__(image); } @@ -1272,11 +1225,19 @@ export class Image { * @internal * @ignore */ - protected __resize__(width: number, height: number, mode: ResizeMode = ResizeModes.BILINEAR, fast = true, region?: Region): Image { - if (width === ResizeModes.AUTO && height === ResizeModes.AUTO) throw new Error('RESIZE_AUTO can only be used for either width or height, not for both'); - else if (width === ResizeModes.AUTO) width = Math.max(1, Math.round(this.width / this.height * Math.floor(height))); - else if (height === ResizeModes.AUTO) height = Math.max(1, Math.round(this.height / this.width * Math.floor(width))); + protected __resized_size__(width: number | undefined, height: number | undefined): [width: number, height: number] { + // a side left out follows the other one, keeping the aspect ratio + if (width === undefined) return [Math.max(1, Math.round(this.width / this.height * Math.floor(height as number))), height as number]; + if (height === undefined) return [width, Math.max(1, Math.round(this.height / this.width * Math.floor(width)))]; + return [width, height]; + } + /** @private */ + /** + * @internal + * @ignore + */ + protected __resize__(width: number, height: number, mode: ResizeMode = ResizeModes.BILINEAR, fast = true, region?: Region): Image { width = Math.floor(width); height = Math.floor(height); if (width < 1) @@ -3227,24 +3188,6 @@ export class Image { return this.__apply__(out); } - /** - * @deprecated Use {@link SVGScaleModes.SCALE}, `Image.SVG_MODE_*` will be removed in 4.0 - * @group Deprecated - */ - static readonly SVG_MODE_SCALE = 1; - - /** - * @deprecated Use {@link SVGScaleModes.WIDTH}, `Image.SVG_MODE_*` will be removed in 4.0 - * @group Deprecated - */ - static readonly SVG_MODE_WIDTH = 2; - - /** - * @deprecated Use {@link SVGScaleModes.HEIGHT}, `Image.SVG_MODE_*` will be removed in 4.0 - * @group Deprecated - */ - static readonly SVG_MODE_HEIGHT = 3; - /** * Creates a new image from the given SVG * Supports shapes, paths, transforms, use/symbol, CSS styles, gradients, patterns, strokes, markers, clip paths, @@ -3273,8 +3216,7 @@ export class Image { */ static renderSVG(svg: string | BinaryData, size?: number, mode?: SVGScaleMode): Promise; static async renderSVG(svg: string | BinaryData, sizeOrOptions: number | SVGOptions = 1, modeArgument: SVGScaleMode = SVGScaleModes.SCALE): Promise { - const { size = 1, mode: givenMode = SVGScaleModes.SCALE, fonts = [], images: givenImages = {} } = isOptions(sizeOrOptions) ? sizeOrOptions : { size: sizeOrOptions, mode: modeArgument }; - const mode = LEGACY_SVG_MODES.get(givenMode) ?? givenMode; + const { size = 1, mode = SVGScaleModes.SCALE, fonts = [], images: givenImages = {} } = isOptions(sizeOrOptions) ? sizeOrOptions : { size: sizeOrOptions, mode: modeArgument }; if (mode !== 'scale' && mode !== 'width' && mode !== 'height') throw new Error('Invalid SVG scaling mode'); @@ -3486,31 +3428,23 @@ interface HSLA { a: number; } -// the deprecated upper case resize modes (Image.RESIZE_*) and the names they stand for -const LEGACY_RESIZE_MODES = new Map([ - ['RESIZE_NEAREST_NEIGHBOR', 'nearest-neighbor'], ['RESIZE_BILINEAR', 'bilinear'], ['RESIZE_BICUBIC', 'bicubic'], ['RESIZE_AREA', 'area'] -]); - /** - * The width, height and mode of resize()'s signatures, a side missing from the options as {@link ResizeModes.AUTO} + * The width, height and mode of resize()'s signatures: a side left out of the options is `undefined` * @internal */ -export function resizeArguments(widthOrOptions: number | ResizeToOptions, height?: number, modeOrOptions?: ResizeMode | ResizeOptions): [width: number, height: number, modeOrOptions: ResizeMode | ResizeOptions | undefined] { - if (!isOptions(widthOrOptions)) return [widthOrOptions, height as number, modeOrOptions]; +export function resizeArguments(widthOrOptions: number | ResizeToOptions, height?: number, modeOrOptions?: ResizeMode | ResizeOptions): [width: number | undefined, height: number | undefined, modeOrOptions: ResizeMode | ResizeOptions | undefined] { + if (!isOptions(widthOrOptions)) return [widthOrOptions, height, modeOrOptions]; const { width, height: optionsHeight, ...options } = widthOrOptions; if (width === undefined && optionsHeight === undefined) throw new TypeError('resize() needs a width, a height or both'); - return [width ?? ResizeModes.AUTO, optionsHeight ?? ResizeModes.AUTO, options]; + return [width, optionsHeight, options]; } function resizeOptions(modeOrOptions?: ResizeMode | ResizeOptions): { mode: ResizeMode, fast: boolean } { const options = isOptions(modeOrOptions) ? modeOrOptions : { mode: modeOrOptions }; const mode = options.mode ?? ResizeModes.BILINEAR; - return { mode: LEGACY_RESIZE_MODES.get(mode) ?? mode, fast: options.fast ?? true }; + return { mode, fast: options.fast ?? true }; } -// the deprecated SVG scale mode numbers (Image.SVG_MODE_*) and the names they stand for -const LEGACY_SVG_MODES = new Map([[1, 'scale'], [2, 'width'], [3, 'height']]); - // separable blend functions of normalized channel values (W3C Compositing and Blending Level 1) const hardLight = (b: number, s: number): number => s <= 0.5 ? b * 2 * s : 1 - (1 - b) * (1 - (2 * s - 1)); diff --git a/src/index.ts b/src/index.ts index 081b70ca..7567637f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ export { Image, ResizeModes, SVGScaleModes } from './image.ts'; export { Colors } from './colors.ts'; export type { Color, ColorFunction, ResizeMode, SVGScaleMode, PNGMetadata, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, AdjustOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, SVGOptions, TextOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts'; export { Frame, FrameDisposalModes } from './frame.ts'; -export type { FrameDisposalModeName, FrameDisposalModeId, FrameOptions } from './frame.ts'; +export type { FrameDisposalModeName, FrameOptions } from './frame.ts'; export { GainMap } from './gainmap.ts'; export type { GainMapOptions, GainMapEncodeOptions } from './gainmap.ts'; export { GIF } from './gif.ts'; diff --git a/tests/avif-encode.js b/tests/avif-encode.js index debbd0aa..72387563 100644 --- a/tests/avif-encode.js +++ b/tests/avif-encode.js @@ -72,7 +72,7 @@ const compare = (a, b) => { // threads (rows searched in a wavefront, filter trials in parallel): the same files as without, with alpha and // with several tiles too (each just above its effort's size for threads, to keep the test fast) { - const big = photo.clone().resize(448, Image.RESIZE_AUTO); + const big = photo.clone().resize({width: 448}); for (let i = 3; i < big.bitmap.length; i += 4) big.bitmap[i] = (i >> 2) % big.width; const wide = new Image(4160, 128); for (let y = 0; y < 128; y++) for (let x = 0; x < 4160; x++) wide.setPixelAt(x, y, Image.rgbaToColor(x * 37 & 255, y * 71 & 255, (x ^ y) & 255, 255)); diff --git a/tests/edge/smoke.mjs b/tests/edge/smoke.mjs index 83777db9..638bd633 100644 --- a/tests/edge/smoke.mjs +++ b/tests/edge/smoke.mjs @@ -12,7 +12,7 @@ export async function smoke() { results[name] = `FAIL ${e && e.stack || e}`; } }; - const photo = (await Image.decode(png)).resize(256, Image.RESIZE_AUTO), alpha = photo.clone(); + const photo = (await Image.decode(png)).resize({width: 256}), alpha = photo.clone(); for (let i = 3; i < alpha.bitmap.length; i += 4) alpha.bitmap[i] = (i >> 2) % 256; // (decoded back, the same size) const back = async data => { diff --git a/tests/endian.js b/tests/endian.js index 54bdcfc4..f16266b3 100644 --- a/tests/endian.js +++ b/tests/endian.js @@ -31,7 +31,7 @@ const close = (a, b) => { 'JPEG XL decoding': async () => (await Image.decode(await fs.readFile('./tests/jxl/jpeg.jxl'))).bitmap, 'JPEG encoding': () => photo.encodeJPEG({quality: 80, metadata: {creationTime: 0}}), 'WebP encoding': () => photo.encodeWebP({quality: 75, metadata: {creationTime: 0}}), - resizing: () => photo.clone().resize(317, Image.RESIZE_AUTO).bitmap, + resizing: () => photo.clone().resize({width: 317}).bitmap, blurring: () => photo.clone().blur(4).bitmap, rotating: () => photo.clone().rotate(30).bitmap, }; diff --git a/tests/gif.js b/tests/gif.js index 2e13e759..a0a4064f 100644 --- a/tests/gif.js +++ b/tests/gif.js @@ -1,6 +1,6 @@ const fs = require('fs').promises; const crypto = require('crypto'); -const {Frame, GIF} = require('..'); +const {Frame, FrameDisposalModes, GIF} = require('..'); const panic = message => { console.error(message); @@ -59,9 +59,9 @@ const panic = message => { const background = new Frame(4, 4, 100); background.fill(black); - const dot = new Frame(1, 1, 100, 1, 1, Frame.DISPOSAL_PREVIOUS); + const dot = new Frame(1, 1, 100, 1, 1, FrameDisposalModes.PREVIOUS); dot.fill(white); - const hole = new Frame(2, 2, 100, 2, 2, Frame.DISPOSAL_BACKGROUND); + const hole = new Frame(2, 2, 100, 2, 2, FrameDisposalModes.BACKGROUND); hole.fill((x, y) => x === y ? white : 0); const last = new Frame(1, 1, 100, 0, 0); last.fill(white); diff --git a/tests/jxl-encode.js b/tests/jxl-encode.js index ae13cc0f..6cc01337 100644 --- a/tests/jxl-encode.js +++ b/tests/jxl-encode.js @@ -156,7 +156,7 @@ const codestream = jxl => { // threads (XYB, the quantization field, the varblock search, the coefficients and their tokens in parallel): the // same files as without; at effort 0 from 1024x1024 pixels on, with alpha { - const big = photo.clone().resize(640, Image.RESIZE_AUTO), large = photo.clone().resize(1024, 1032); + const big = photo.clone().resize({width: 640}), large = photo.clone().resize(1024, 1032); for (let i = 3; i < large.bitmap.length; i += 4) large.bitmap[i] = (i >> 2) % large.width & 255; for (const [image, effort] of [[big, 1], [big, 2], [large, 0]]) { const one = await image.encodeJXL({quality: 85, effort, threads: 1, metadata: {creationTime: 0}}); diff --git a/tests/misc.js b/tests/misc.js index 82b07661..16c77d1a 100644 --- a/tests/misc.js +++ b/tests/misc.js @@ -132,11 +132,6 @@ if (Image.hslaToColor(0, 0, 1, 0xff) !== 0xffffffff) panic('resize with invalid mode failed'); } catch { } - try { - image.resize(Image.RESIZE_AUTO, Image.RESIZE_AUTO); - panic('resize with RESIZE_AUTO for both x and y failed'); - } catch { - } } { diff --git a/tests/options.js b/tests/options.js index 15f7fdaf..47541347 100644 --- a/tests/options.js +++ b/tests/options.js @@ -1,5 +1,5 @@ const fs = require('fs').promises; -const {Image, Frame, Animation, GIF, decode} = require('..'); +const {Image, Frame, Animation, GIF, decode, ResizeModes, SVGScaleModes} = require('..'); const panic = message => { console.error(message); @@ -17,9 +17,9 @@ const same = (name, a, b) => { const tile = () => photo.clone().resize(64, 48); const logo = new Image(16, 16).fill(0xff000080); - same('scale', tile().scale(0.5, Image.RESIZE_NEAREST_NEIGHBOR), tile().scale(0.5, {mode: Image.RESIZE_NEAREST_NEIGHBOR})); + same('scale', tile().scale(0.5, ResizeModes.NEAREST_NEIGHBOR), tile().scale(0.5, {mode: ResizeModes.NEAREST_NEIGHBOR})); for (const method of ['resize', 'contain', 'fit', 'cover']) - same(method, tile()[method](30, 40, Image.RESIZE_BICUBIC), tile()[method](30, 40, {mode: Image.RESIZE_BICUBIC})); + same(method, tile()[method](30, 40, ResizeModes.BICUBIC), tile()[method](30, 40, {mode: ResizeModes.BICUBIC})); // the default mode same('resize default', tile().resize(30, 40), tile().resize(30, 40, {})); @@ -49,7 +49,7 @@ const same = (name, a, b) => { if (!Buffer.from(await tile()[method]({metadata: {author}})).includes(author)) panic(`${method}: metadata missing`); const svg = await fs.readFile('./tests/svgs/crown.svg'); - same('renderSVG', await Image.renderSVG(svg, 40, Image.SVG_MODE_WIDTH), await Image.renderSVG(svg, {size: 40, mode: Image.SVG_MODE_WIDTH})); + same('renderSVG', await Image.renderSVG(svg, 40, SVGScaleModes.WIDTH), await Image.renderSVG(svg, {size: 40, mode: SVGScaleModes.WIDTH})); const font = await fs.readFile('./tests/fonts/opensans bold.ttf'); same('renderText', await Image.renderText(font, 24, 'Options', 0x3366ffff, undefined, {strokeWidth: 2, strokeColor: 0xffffffff}), @@ -77,8 +77,8 @@ const same = (name, a, b) => { for (const method of ['encodeGIF', 'encodeAPNG', 'encodeWebP']) if (!Buffer.from(await animation[method]({metadata: {author, comment: author}})).includes(author)) panic(`${method}: metadata missing`); - same('Animation.resize', (await Animation.decode(await animation.encodeAPNG())).resize(20, 10, Image.RESIZE_NEAREST_NEIGHBOR)[1], - (await Animation.decode(await animation.encodeAPNG())).resize(20, 10, {mode: Image.RESIZE_NEAREST_NEIGHBOR})[1]); + same('Animation.resize', (await Animation.decode(await animation.encodeAPNG())).resize(20, 10, ResizeModes.NEAREST_NEIGHBOR)[1], + (await Animation.decode(await animation.encodeAPNG())).resize(20, 10, {mode: ResizeModes.NEAREST_NEIGHBOR})[1]); const gif = await animation.encodeGIF(); for (const decoded of [await Animation.decode(gif, {onlyFirstFrame: true}), await GIF.decode(gif, {onlyFirstFrame: true}), await decode(gif, {onlyFirstFrame: true})]) diff --git a/tests/regressions.js b/tests/regressions.js index 849065d0..46239982 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -89,7 +89,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); // frames keep their metadata { - const frame = new Frame(10, 10, 500, 2, 4, Frame.DISPOSAL_BACKGROUND); + const frame = new Frame(10, 10, 500, 2, 4, FrameDisposalModes.BACKGROUND); const clone = frame.clone(); assert(clone instanceof Frame && clone.duration === 500 && clone.xOffset === 2 && clone.disposalMode === 'background', 'Frame.clone lost metadata'); @@ -241,7 +241,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); // resizing filters average instead of skipping pixels, in premultiplied alpha { const checker = new Image(64, 64).fill((x, y) => (x + y) & 1 ? 0xffffffff : 0x000000ff); - for (const mode of [Image.RESIZE_BILINEAR, Image.RESIZE_BICUBIC, Image.RESIZE_AREA]) { + for (const mode of [ResizeModes.BILINEAR, ResizeModes.BICUBIC, ResizeModes.AREA]) { const small = checker.clone().resize(8, 8, mode); for (const [x, y, color] of small.iterateWithColors()) assert(Math.abs((color >>> 24) - 128) <= 2, `${mode}: checkerboard should average to grey at ${x},${y}, got ${color >>> 24}`); @@ -251,12 +251,15 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); const half = new Image(4, 1).fill((x) => x < 2 ? 0xff000000 : 0x0000ffff).resize(2, 1); assert(rgba(half, 0, 0).slice(0, 3).join() === '0,0,255', 'resize: transparent pixels bled into the result: ' + rgba(half, 0, 0)); - const nearest = new Image(4, 1).fill((x) => Image.rgbToColor(x, 0, 0)).resize(2, 1, Image.RESIZE_NEAREST_NEIGHBOR); + const nearest = new Image(4, 1).fill((x) => Image.rgbToColor(x, 0, 0)).resize(2, 1, ResizeModes.NEAREST_NEIGHBOR); assert(rgba(nearest, 0, 0)[0] === 1 && rgba(nearest, 1, 0)[0] === 3, 'nearest neighbor should sample pixel centers'); - let threw = false; - try { checker.clone().resize(8, 8, 'RESIZE_FOO'); } catch { threw = true; } - assert(threw, 'resize should reject unknown modes'); + // (the upper case names of the removed Image.RESIZE_* constants are unknown modes too) + for (const unknown of ['RESIZE_FOO', 'RESIZE_BILINEAR', 'RESIZE_AREA']) { + let threw = false; + try { checker.clone().resize(8, 8, unknown); } catch { threw = true; } + assert(threw, `resize should reject unknown mode ${unknown}`); + } } // circles are antialiased and symmetric around the pixel center @@ -372,58 +375,41 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); // disposal modes read back as the names they are set with { - const frame = new Frame(1, 1, 100, 0, 0, Frame.DISPOSAL_PREVIOUS); + const frame = new Frame(1, 1, 100, 0, 0, FrameDisposalModes.PREVIOUS); assert(frame.disposalMode === 'previous', 'disposalMode should be a name, got ' + frame.disposalMode); - frame.disposalMode = 0; - assert(frame.disposalMode === Frame.DISPOSAL_ANY, 'disposalMode set by id should read back as name'); + // the numeric ids (0-3) are not accepted any more, the names are + let threw = false; + try { frame.disposalMode = 0; } catch (e) { threw = e instanceof RangeError; } + assert(threw && frame.disposalMode === 'previous', 'disposalMode should only take names'); const decoded = await GIF.decode(await new GIF([new Frame(2, 2, 100, 0, 0, 'background'), new Frame(2, 2).fill(0xff0000ff)]).encode()); assert(decoded.length === 2, 'gif with disposal modes should roundtrip'); } - // the constant classes hold the new names, the deprecated Image/Frame constants (removed in 4.0) keep their values, - // and both work everywhere with the same results + // the constant classes hold the names of the modes (the deprecated Image/Frame constants were removed in 4.0) { - const pairs = [ - [Image.RESIZE_NEAREST_NEIGHBOR, 'RESIZE_NEAREST_NEIGHBOR', ResizeModes.NEAREST_NEIGHBOR, 'nearest-neighbor'], - [Image.RESIZE_BILINEAR, 'RESIZE_BILINEAR', ResizeModes.BILINEAR, 'bilinear'], - [Image.RESIZE_BICUBIC, 'RESIZE_BICUBIC', ResizeModes.BICUBIC, 'bicubic'], - [Image.RESIZE_AREA, 'RESIZE_AREA', ResizeModes.AREA, 'area'], - [Image.RESIZE_AUTO, -1, ResizeModes.AUTO, -1], - [Frame.RESIZE_AUTO, -1, ResizeModes.AUTO, -1], - [Image.SVG_MODE_SCALE, 1, SVGScaleModes.SCALE, 'scale'], - [Image.SVG_MODE_WIDTH, 2, SVGScaleModes.WIDTH, 'width'], - [Image.SVG_MODE_HEIGHT, 3, SVGScaleModes.HEIGHT, 'height'], - [Frame.DISPOSAL_ANY, 'any', FrameDisposalModes.ANY, 'any'], - [Frame.DISPOSAL_KEEP, 'keep', FrameDisposalModes.KEEP, 'keep'], - [Frame.DISPOSAL_PREVIOUS, 'previous', FrameDisposalModes.PREVIOUS, 'previous'], - [Frame.DISPOSAL_BACKGROUND, 'background', FrameDisposalModes.BACKGROUND, 'background'] - ]; - for (const [old, oldValue, now, nowValue] of pairs) - assert(old === oldValue && now === nowValue, `constants: ${old} should be ${oldValue}, ${now} should be ${nowValue}`); + assert(ResizeModes.NEAREST_NEIGHBOR === 'nearest-neighbor' && ResizeModes.BILINEAR === 'bilinear' && ResizeModes.BICUBIC === 'bicubic' && ResizeModes.AREA === 'area', 'ResizeModes values'); + assert(SVGScaleModes.SCALE === 'scale' && SVGScaleModes.WIDTH === 'width' && SVGScaleModes.HEIGHT === 'height', 'SVGScaleModes values'); + assert(FrameDisposalModes.ANY === 'any' && FrameDisposalModes.KEEP === 'keep' && FrameDisposalModes.PREVIOUS === 'previous' && FrameDisposalModes.BACKGROUND === 'background', 'FrameDisposalModes values'); const svg = ''; - for (const [old, now, size, width, height] of [[Image.SVG_MODE_SCALE, SVGScaleModes.SCALE, 2, 20, 10], [Image.SVG_MODE_WIDTH, SVGScaleModes.WIDTH, 20, 20, 10], [Image.SVG_MODE_HEIGHT, SVGScaleModes.HEIGHT, 20, 40, 20]]) { - const a = await Image.renderSVG(svg, size, old), b = await Image.renderSVG(svg, {size, mode: now}); - assert(a.width === width && a.height === height, `renderSVG mode ${now}: ${a.width}x${a.height}`); - assert(b.width === width && b.height === height && Buffer.from(a.bitmap).equals(Buffer.from(b.bitmap)), `renderSVG mode ${old} vs ${now}`); + for (const [mode, size, width, height] of [[SVGScaleModes.SCALE, 2, 20, 10], [SVGScaleModes.WIDTH, 20, 20, 10], [SVGScaleModes.HEIGHT, 20, 40, 20]]) { + const rendered = await Image.renderSVG(svg, {size, mode}); + assert(rendered.width === width && rendered.height === height, `renderSVG mode ${mode}: ${rendered.width}x${rendered.height}`); } + assert(new Image(8, 4).resize({width: 4, mode: ResizeModes.AREA}).height === 2, 'resize({width}) with a mode keeps the aspect ratio'); const photo = new Image(9, 7).fill((x, y) => ColorHelpers.fromRGBA(x * 28, y * 36, (x ^ y) * 16, 255 - x * 10)); - for (const [old, now] of pairs.slice(0, 4)) - for (const method of ['resize', 'contain', 'fit', 'cover']) - assert(Buffer.from(photo.clone()[method](5, 4, old).bitmap).equals(Buffer.from(photo.clone()[method](5, 4, {mode: now}).bitmap)), `${method} ${old} vs ${now}`); - assert(new Image(8, 4).resize(4, Image.RESIZE_AUTO, Image.RESIZE_AREA).height === 2, 'resize with deprecated constants'); let threw = false; try { photo.clone().resize(4, 4, 'Area'); } catch { threw = true; } assert(threw, 'resize modes are case sensitive'); } - // resize({width}) / resize({height}) keep the aspect ratio like ResizeModes.AUTO (deprecated, removed in 4.0) + // resize({width}) / resize({height}) keep the aspect ratio { const {Animation} = require('..'); const photo = new Image(12, 8).fill((x, y) => ColorHelpers.fromRGBA(x * 20, y * 30, 90, 255)); const same = (a, b) => a.width === b.width && a.height === b.height && Buffer.from(a.bitmap).equals(Buffer.from(b.bitmap)); - assert(same(photo.clone().resize({width: 6}), photo.clone().resize(6, Image.RESIZE_AUTO)), 'resize({width})'); + assert(same(photo.clone().resize({width: 6}), photo.clone().resize(6, 4)), 'resize({width})'); assert(photo.clone().resize({width: 6}).height === 4, 'resize({width}) keeps the aspect ratio'); - assert(same(photo.clone().resize({height: 4, mode: 'bicubic'}), photo.clone().resize(ResizeModes.AUTO, 4, Image.RESIZE_BICUBIC)), 'resize({height, mode})'); + assert(same(photo.clone().resize({height: 4, mode: 'bicubic'}), photo.clone().resize(6, 4, {mode: ResizeModes.BICUBIC})), 'resize({height, mode})'); assert(same(photo.clone().resize({width: 5, height: 9, fast: false}), photo.clone().resize(5, 9, {fast: false})), 'resize({width, height, fast})'); let threw = false; try { photo.clone().resize({}); } catch (e) { threw = e instanceof TypeError; } @@ -436,7 +422,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); const animation = new Animation([Frame.from(photo.clone(), 100), Frame.from(photo.clone().invert(), 100)]); const other = new Animation([Frame.from(photo.clone(), 100), Frame.from(photo.clone().invert(), 100)]); animation.resize({height: 4}); - other.resize(Image.RESIZE_AUTO, 4); + other.resize(6, 4); assert(animation.width === 6 && animation.height === 4 && [...animation].every((f, i) => same(f, other[i])), 'Animation.resize({height})'); } @@ -1071,14 +1057,14 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); let defs = ''; for (let i = 1; i <= 9; i++) defs += `${``.repeat(10)}`; let start = Date.now(), error = null; - try { await Image.renderSVG(`${defs}`, {size: 100, mode: Image.SVG_MODE_WIDTH}); } catch (err) { error = err; } + try { await Image.renderSVG(`${defs}`, {size: 100, mode: SVGScaleModes.WIDTH}); } catch (err) { error = err; } assert(error && / references/.test(error.message) && Date.now() - start < 3000, `billion laughs should throw quickly (${error && error.message}, ${Date.now() - start} ms)`); start = Date.now(); - const dashed = await Image.renderSVG(``, {size: 100, mode: Image.SVG_MODE_WIDTH}); + const dashed = await Image.renderSVG(``, {size: 100, mode: SVGScaleModes.WIDTH}); assert(Date.now() - start < 3000 && dashed.getRGBAAt(50, 50)[3] === 255, 'a dash pattern too fine to dash should stroke solid, quickly'); - const nested = await Image.renderSVG(`${''.repeat(100000)}${''.repeat(100000)}`, {size: 100, mode: Image.SVG_MODE_WIDTH}); + const nested = await Image.renderSVG(`${''.repeat(100000)}${''.repeat(100000)}`, {size: 100, mode: SVGScaleModes.WIDTH}); assert(nested.width === 100, 'deeply nested elements should render'); } { diff --git a/tests/resize.js b/tests/resize.js index 15f151be..ea90b769 100644 --- a/tests/resize.js +++ b/tests/resize.js @@ -1,10 +1,10 @@ const fs = require('fs').promises; -const {Image} = require('..'); +const {Image, ResizeModes} = require('..'); const samePixels = require('./util/pixels'); (async () => { const binary = await fs.readFile('./tests/targets/image.png'); const image = await Image.decode(binary); - image.resize(image.width / 4, Image.RESIZE_AUTO); + image.resize({width: image.width / 4}); const encoded = await image.encode(1, {creationTime: 0, software: ''}); @@ -24,11 +24,11 @@ const samePixels = require('./util/pixels'); spot.bitmap[(spot.height >> 1) * spot.width * 4 + 403] = 128; const sizes = [[0.5, 0.5], [1 / 3, 0.4], [2, 1.5], [1, 0.7]]; for (const source of [opaque, transparent, spot]) - for (const mode of [Image.RESIZE_BILINEAR, Image.RESIZE_AREA, Image.RESIZE_BICUBIC]) + for (const mode of [ResizeModes.BILINEAR, ResizeModes.AREA, ResizeModes.BICUBIC]) for (const [sx, sy] of sizes) { const width = Math.round(source.width * sx), height = Math.round(source.height * sy); const a = source.clone().resize(width, height, {mode, fast: false}).bitmap, b = source.clone().resize(width, height, {mode}).bitmap; - const limit = mode === Image.RESIZE_BICUBIC ? 0 : source === opaque ? 1 : 2.5; + const limit = mode === ResizeModes.BICUBIC ? 0 : source === opaque ? 1 : 2.5; let max = 0, differ = false; for (let i = 0; i < a.length; i++) { if (a[i] === b[i]) continue; @@ -36,7 +36,7 @@ const samePixels = require('./util/pixels'); const alpha = (i & 3) === 3, al = a[i | 3], bl = b[i | 3]; max = Math.max(max, source === opaque || alpha ? Math.abs(a[i] - b[i]) : Math.abs(a[i] * al - b[i] * bl) / 255); } - if (max > limit || mode !== Image.RESIZE_BICUBIC && sx === 0.5 && !differ) { + if (max > limit || mode !== ResizeModes.BICUBIC && sx === 0.5 && !differ) { console.error('fast resize', mode, sx, sy, source === opaque ? 'opaque' : source === spot ? 'spot' : 'transparent', max); process.exit(1); } diff --git a/tests/svg.js b/tests/svg.js index 266aa6bb..9470486a 100644 --- a/tests/svg.js +++ b/tests/svg.js @@ -1,4 +1,4 @@ -const {Image} = require('..'); +const {Image, SVGScaleModes} = require('..'); const fs = require('fs').promises; const samePixels = require('./util/pixels'); @@ -10,7 +10,7 @@ const panic = message => { (async () => { { const svg = await fs.readFile('./tests/svgs/potato.svg'); - const image = await Image.renderSVG(svg.toString(), 256 / 36, Image.SVG_MODE_SCALE); + const image = await Image.renderSVG(svg.toString(), 256 / 36, SVGScaleModes.SCALE); const encoded = await image.encode(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) @@ -23,7 +23,7 @@ const panic = message => { { // gradients, clip paths, masks, style sheets, symbols, strokes and fill rules const svg = await fs.readFile('./tests/svgs/features.svg'); - const image = await Image.renderSVG(svg, 480, Image.SVG_MODE_WIDTH); + const image = await Image.renderSVG(svg, 480, SVGScaleModes.WIDTH); if (image.width !== 480 || image.height !== 320) panic('features: unexpected size'); const encoded = await image.encode(1, {creationTime: 0, software: ''}); diff --git a/tests/util/resvg.js b/tests/util/resvg.js index 1e3dad30..2505e3b7 100644 --- a/tests/util/resvg.js +++ b/tests/util/resvg.js @@ -1,4 +1,4 @@ -const {Image} = require('../..'); +const {Image, SVGScaleModes} = require('../..'); const fs = require('fs').promises; // resvg's test fonts (tests/fonts/resvg, subsets with the characters of the test cases): its default family (serif) @@ -12,7 +12,7 @@ const fonts = async () => { // test cases of resvg (tests/svgs/resvg, see its LICENSE) next to resvg's renderings, of the directories some returns // true for. Antialiasing differs a little: at most 0.5% of the pixels may differ by more than 24 (premultiplied) module.exports = async (some, panic) => { - const root = './tests/svgs/resvg', options = {mode: Image.SVG_MODE_WIDTH, fonts: await fonts()}; + const root = './tests/svgs/resvg', options = {mode: SVGScaleModes.WIDTH, fonts: await fonts()}; const files = (await fs.readdir(root, {recursive: true})).filter(f => f.endsWith('.svg') && some(f.replace(/\\/g, '/'))); for (const file of files) { const reference = await Image.decode(await fs.readFile(`${root}/${file.replace(/\.svg$/, '.png')}`)); From b0523b8d1f20ee977a30d46737071897268805a0 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 16:08:19 +0000 Subject: [PATCH 002/188] remove the absolute argument of the adjust methods --- CHANGELOG.md | 2 + src/image.ts | 110 ++++++++----------------------------------- src/index.ts | 2 +- tests/avif-encode.js | 2 +- tests/options.js | 5 -- 5 files changed, 23 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4c84a44..e0ba8eae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - **The deprecated resize, SVG and frame disposal constants are removed**: `Image.RESIZE_NEAREST_NEIGHBOR`, `Image.RESIZE_BILINEAR`, `Image.RESIZE_BICUBIC`, `Image.RESIZE_AREA`, `Image.SVG_MODE_SCALE`, `Image.SVG_MODE_WIDTH`, `Image.SVG_MODE_HEIGHT` and `Frame.DISPOSAL_*` are gone: use `ResizeModes`, `SVGScaleModes` and `FrameDisposalModes` instead. `ResizeModes.AUTO` (and `Image.RESIZE_AUTO`) is removed too: pass only the width or the height, `image.resize({ width: 256 })`. The old values are no longer accepted either: `'RESIZE_BILINEAR'`, the numbers 1-3 for SVG scale modes and the numeric disposal ids 0-3. +- **`absolute` is removed from `opacity()`, `red()`, `green()`, `blue()`, `lightness()` and `saturation()`**: they only scale now, `image.red(0.5)` (and `AdjustOptions`, the type of their options, is removed). For a fixed color, use `image.recolor()`. + ## 3.20.1 ### Performance diff --git a/src/image.ts b/src/image.ts index 2b97e987..17d91c32 100644 --- a/src/image.ts +++ b/src/image.ts @@ -137,18 +137,6 @@ export interface ResizeOptions { fast?: boolean; } -/** - * Options of {@link Image.opacity}, {@link Image.red}, {@link Image.green}, {@link Image.blue}, {@link Image.lightness} - * and {@link Image.saturation} - */ -export interface AdjustOptions { - /** - * Whether to set the value (true) or scale the current one (false). Default: `false` - * @deprecated Removed in 4.0: scale with a factor, or use {@link Image.recolor} for a fixed color - */ - absolute?: boolean; -} - /** Options of {@link Image.sharpen} */ export interface SharpenOptions { /** How strongly to sharpen (0 does nothing). Default: `1` */ @@ -1613,23 +1601,13 @@ export class Image { /** * Scales the opacity of the image * @param opacity The factor to multiply the opacity with (0..1 lowers it, >1 raises it, results are clamped) - * @param options See {@link AdjustOptions} */ - opacity(opacity: number, options?: AdjustOptions): this; - /** - * Scales the opacity of the image - * @param opacity The factor to multiply the opacity with (0..1 lowers it, >1 raises it, results are clamped) - * @param absolute Whether to set the opacity (true) instead of scaling it (false) - * @deprecated Use {@link Image.opacity} to scale; for a fixed alpha, `absolute` is removed in 4.0 - */ - opacity(opacity: number, absolute?: boolean): this; - opacity(opacity: number, absoluteOrOptions: boolean | AdjustOptions = false): this { - const absolute = isOptions(absoluteOrOptions) ? absoluteOrOptions.absolute ?? false : absoluteOrOptions; + opacity(opacity: number): this { opacity = finite(opacity, 'opacity value'); if (opacity < 0) throw new RangeError('Invalid opacity value'); - this.__set_channel_value__(opacity, absolute, 3); + this.__set_channel_value__(opacity, 3); return this; } @@ -1637,24 +1615,14 @@ export class Image { /** * Scales the red channel * @param factor The factor to multiply the channel with (>= 0, results are clamped) - * @param options See {@link AdjustOptions} */ - red(factor: number, options?: AdjustOptions): this; - /** - * Scales the red channel - * @param factor The factor to multiply the channel with (>= 0, results are clamped) - * @param absolute Whether to set every pixel's red channel to `factor` * 255 (true) instead of scaling it (false) - * @deprecated Use {@link Image.recolor} to set a fixed color; `absolute` is removed in 4.0 - */ - red(factor: number, absolute?: boolean): this; - red(factor: number, absoluteOrOptions: boolean | AdjustOptions = false): this { - if (this.__gain_map__) return this.__recolor__(image => image.red(factor, absoluteOrOptions as boolean)); - const absolute = isOptions(absoluteOrOptions) ? absoluteOrOptions.absolute ?? false : absoluteOrOptions; + red(factor: number): this { + if (this.__gain_map__) return this.__recolor__(image => image.red(factor)); factor = finite(factor, 'red factor'); if (factor < 0) throw new RangeError('Invalid red factor'); - this.__set_channel_value__(factor, absolute, 0); + this.__set_channel_value__(factor, 0); return this; } @@ -1662,24 +1630,14 @@ export class Image { /** * Scales the green channel * @param factor The factor to multiply the channel with (>= 0, results are clamped) - * @param options See {@link AdjustOptions} - */ - green(factor: number, options?: AdjustOptions): this; - /** - * Scales the green channel - * @param factor The factor to multiply the channel with (>= 0, results are clamped) - * @param absolute Whether to set every pixel's green channel to `factor` * 255 (true) instead of scaling it (false) - * @deprecated Use {@link Image.recolor} to set a fixed color; `absolute` is removed in 4.0 */ - green(factor: number, absolute?: boolean): this; - green(factor: number, absoluteOrOptions: boolean | AdjustOptions = false): this { - if (this.__gain_map__) return this.__recolor__(image => image.green(factor, absoluteOrOptions as boolean)); - const absolute = isOptions(absoluteOrOptions) ? absoluteOrOptions.absolute ?? false : absoluteOrOptions; + green(factor: number): this { + if (this.__gain_map__) return this.__recolor__(image => image.green(factor)); factor = finite(factor, 'green factor'); if (factor < 0) throw new RangeError('Invalid green factor'); - this.__set_channel_value__(factor, absolute, 1); + this.__set_channel_value__(factor, 1); return this; } @@ -1687,24 +1645,14 @@ export class Image { /** * Scales the blue channel * @param factor The factor to multiply the channel with (>= 0, results are clamped) - * @param options See {@link AdjustOptions} - */ - blue(factor: number, options?: AdjustOptions): this; - /** - * Scales the blue channel - * @param factor The factor to multiply the channel with (>= 0, results are clamped) - * @param absolute Whether to set every pixel's blue channel to `factor` * 255 (true) instead of scaling it (false) - * @deprecated Use {@link Image.recolor} to set a fixed color; `absolute` is removed in 4.0 */ - blue(factor: number, absolute?: boolean): this; - blue(factor: number, absoluteOrOptions: boolean | AdjustOptions = false): this { - if (this.__gain_map__) return this.__recolor__(image => image.blue(factor, absoluteOrOptions as boolean)); - const absolute = isOptions(absoluteOrOptions) ? absoluteOrOptions.absolute ?? false : absoluteOrOptions; + blue(factor: number): this { + if (this.__gain_map__) return this.__recolor__(image => image.blue(factor)); factor = finite(factor, 'blue factor'); if (factor < 0) throw new RangeError('Invalid blue factor'); - this.__set_channel_value__(factor, absolute, 2); + this.__set_channel_value__(factor, 2); return this; } @@ -1713,55 +1661,35 @@ export class Image { * @internal * @ignore */ - protected __set_channel_value__(value: number, absolute: boolean, offset: number): void { + protected __set_channel_value__(value: number, offset: number): void { for (let i = offset; i < this.bitmap.length; i += 4) - this.bitmap[i] = value * (absolute ? 255 : this.bitmap[i]); + this.bitmap[i] = value * this.bitmap[i]; } /** * Scales the lightness of the image (HSL), keeping its hue and saturation * @param value The factor to multiply the lightness with (>= 0, results are clamped) - * @param options See {@link AdjustOptions} */ - lightness(value: number, options?: AdjustOptions): this; - /** - * Scales the lightness of the image (HSL), keeping its hue and saturation - * @param value The factor to multiply the lightness with (>= 0, results are clamped) - * @param absolute Whether to set the lightness (true) instead of scaling it (false) - * @deprecated `absolute` is removed in 4.0: scale with a factor, or use {@link Image.recolor} for a fixed color - */ - lightness(value: number, absolute?: boolean): this; - lightness(value: number, absoluteOrOptions: boolean | AdjustOptions = false): this { - if (this.__gain_map__) return this.__recolor__(image => image.lightness(value, absoluteOrOptions as boolean)); - const absolute = isOptions(absoluteOrOptions) ? absoluteOrOptions.absolute ?? false : absoluteOrOptions; + lightness(value: number): this { + if (this.__gain_map__) return this.__recolor__(image => image.lightness(value)); value = finite(value, 'lightness value'); if (value < 0) throw new RangeError('Invalid lightness value'); - return this.__map_hsla__(c => { c.l = value * (absolute ? 1 : c.l); }); + return this.__map_hsla__(c => { c.l = value * c.l; }); } /** * Scales the saturation of the image (HSL), keeping its hue and lightness. 0 makes it greyscale * @param value The factor to multiply the saturation with (>= 0, results are clamped) - * @param options See {@link AdjustOptions} - */ - saturation(value: number, options?: AdjustOptions): this; - /** - * Scales the saturation of the image (HSL), keeping its hue and lightness. 0 makes it greyscale - * @param value The factor to multiply the saturation with (>= 0, results are clamped) - * @param absolute Whether to set the saturation (true) instead of scaling it (false) - * @deprecated `absolute` is removed in 4.0: scale with a factor, or use {@link Image.recolor} for a fixed color */ - saturation(value: number, absolute?: boolean): this; - saturation(value: number, absoluteOrOptions: boolean | AdjustOptions = false): this { - if (this.__gain_map__) return this.__recolor__(image => image.saturation(value, absoluteOrOptions as boolean), true); - const absolute = isOptions(absoluteOrOptions) ? absoluteOrOptions.absolute ?? false : absoluteOrOptions; + saturation(value: number): this { + if (this.__gain_map__) return this.__recolor__(image => image.saturation(value), true); value = finite(value, 'saturation value'); if (value < 0) throw new RangeError('Invalid saturation value'); - return this.__map_hsla__(c => { c.s = value * (absolute ? 1 : c.s); }); + return this.__map_hsla__(c => { c.s = value * c.s; }); } /** diff --git a/src/index.ts b/src/index.ts index 7567637f..afa817aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,7 @@ import { ImageType } from './image-type.ts'; export { Image, ResizeModes, SVGScaleModes } from './image.ts'; export { Colors } from './colors.ts'; -export type { Color, ColorFunction, ResizeMode, SVGScaleMode, PNGMetadata, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, AdjustOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, SVGOptions, TextOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts'; +export type { Color, ColorFunction, ResizeMode, SVGScaleMode, PNGMetadata, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, SVGOptions, TextOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts'; export { Frame, FrameDisposalModes } from './frame.ts'; export type { FrameDisposalModeName, FrameOptions } from './frame.ts'; export { GainMap } from './gainmap.ts'; diff --git a/tests/avif-encode.js b/tests/avif-encode.js index 72387563..ce1a85f0 100644 --- a/tests/avif-encode.js +++ b/tests/avif-encode.js @@ -50,7 +50,7 @@ const compare = (a, b) => { if (Buffer.compare(Buffer.from(lossless.bitmap), Buffer.from(potato.bitmap))) panic('quality 100 isn\'t lossless'); // gray: monochrome, smaller (the photo has little color: by about 13%), lossless if asked - const gray = photo.clone().saturation(0, {absolute: true}); + const gray = photo.clone().saturation(0); const grayAvif = await gray.encodeAVIF({effort: 0}), grayBack = await Image.decode(grayAvif); if (grayAvif.length >= full.length * 0.92 || compare(gray, grayBack)[0] < 35) panic(`gray: ${grayAvif.length} bytes, ${compare(gray, grayBack)[0].toFixed(2)} dB`); for (let i = 0; i < grayBack.bitmap.length; i += 4) if (grayBack.bitmap[i] !== grayBack.bitmap[i + 1] || grayBack.bitmap[i] !== grayBack.bitmap[i + 2]) panic('gray: colored pixels'); diff --git a/tests/options.js b/tests/options.js index 47541347..ea00bb25 100644 --- a/tests/options.js +++ b/tests/options.js @@ -23,11 +23,6 @@ const same = (name, a, b) => { // the default mode same('resize default', tile().resize(30, 40), tile().resize(30, 40, {})); - for (const method of ['opacity', 'red', 'green', 'blue', 'lightness', 'saturation']) { - same(method, tile()[method](0.5, true), tile()[method](0.5, {absolute: true})); - same(`${method} default`, tile()[method](0.5), tile()[method](0.5, {})); - } - same('composite', tile().composite(logo, 5, 7, {opacity: 0.5, mode: 'multiply'}), tile().composite(logo, {x: 5, y: 7, opacity: 0.5, mode: 'multiply'})); same('composite default', tile().composite(logo), tile().composite(logo, {})); same('sharpen', tile().sharpen(2, 3), tile().sharpen({amount: 2, radius: 3})); From 81758799ea1d25b2226b40013a1a9b154bbba0c7 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 16:10:43 +0000 Subject: [PATCH 003/188] remove Image.encode() --- CHANGELOG.md | 2 ++ src/image.ts | 31 +------------------------------ tests/animation-optimize.js | 2 +- tests/animation.js | 2 +- tests/circle.js | 8 ++++---- tests/crop.js | 4 ++-- tests/edge/smoke.mjs | 2 +- tests/effects.js | 2 +- tests/fill.js | 4 ++-- tests/fisheye.js | 2 +- tests/flip.js | 4 ++-- tests/font.js | 16 ++++++++-------- tests/gainmap.js | 2 +- tests/image.js | 2 +- tests/invert.js | 8 ++++---- tests/metadata.js | 6 +++--- tests/option-checks.js | 2 +- tests/options.js | 4 ++-- tests/png.js | 2 +- tests/readme.js | 2 +- tests/regressions.js | 31 +++++++------------------------ tests/resize.js | 2 +- tests/rotate.js | 10 +++++----- tests/svg-images.js | 8 ++++---- tests/svg.js | 6 +++--- tests/util/without-native.js | 2 +- 26 files changed, 61 insertions(+), 105 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0ba8eae..6f174a1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - **`absolute` is removed from `opacity()`, `red()`, `green()`, `blue()`, `lightness()` and `saturation()`**: they only scale now, `image.red(0.5)` (and `AdjustOptions`, the type of their options, is removed). For a fixed color, use `image.recolor()`. +- **`Image.encode()` is removed**: use `image.encodePNG()`, which takes the same arguments. + ## 3.20.1 ### Performance diff --git a/src/image.ts b/src/image.ts index 17d91c32..10c054e6 100644 --- a/src/image.ts +++ b/src/image.ts @@ -271,7 +271,7 @@ export interface RadialGradientOptions extends GradientOptions { radius?: number; } -/** Options of {@link Image.encode} */ +/** Options of {@link Image.encodePNG} */ export interface PNGEncodeOptions { /** The compression level (0-9), higher is smaller but slower. Default: `4` */ compression?: number; @@ -2837,35 +2837,6 @@ export class Image { return png.encodeRGBA(this.__bytes__(), this.width, this.height, compressionLevel(compression), pngText(resolveMetadata(metadata))); } - /** - * Encodes the image into a PNG - * @param options See {@link PNGEncodeOptions} - * @return The encoded data - * @deprecated Use {@link Image.encodePNG}, `encode()` will be removed in 4.0 - * @group Deprecated - */ - encode(options?: PNGEncodeOptions): Promise; - /** - * Encodes the image into a PNG - * @param compression The compression level (0-9). Default: `4` - * @param metadata Metadata to include, see {@link Metadata} - * @return The encoded data - * @deprecated Use {@link Image.encodePNG}, `encode()` will be removed in 4.0 - * @group Deprecated - */ - encode(compression?: number, metadata?: Metadata): Promise; - /** - * Encodes the image into a PNG with compression level 4 - * @param metadata Metadata to include, see {@link Metadata} - * @return The encoded data - * @deprecated Use {@link Image.encodePNG}, `encode()` will be removed in 4.0 - * @group Deprecated - */ - encode(metadata?: Metadata): Promise; - encode(compressionOrOptions?: number | PNGEncodeOptions | Metadata, metadata?: Metadata): Promise { - return this.encodePNG(compressionOrOptions as number, metadata); - } - /** * Encodes the image into a JPEG * @param options See {@link JPEGEncodeOptions} diff --git a/tests/animation-optimize.js b/tests/animation-optimize.js index 9fd5a68e..3f61d583 100644 --- a/tests/animation-optimize.js +++ b/tests/animation-optimize.js @@ -155,7 +155,7 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { const animation = new Animation(Array.from({length: 20}, (_, i) => Frame.from(background.clone().drawBox(10 + i * 6, 50, 8, 8, 0xffffffff), 50))); await check('screen recording', animation); - const still = (await background.encode()).length; + const still = (await background.encodePNG()).length; for (const format of ['APNG', 'WebP', 'GIF']) { const size = (await animation[`encode${format}`]()).length; if (size > 3 * (format === 'GIF' ? (await new Animation([Frame.from(background)]).encodeGIF()).length : still)) diff --git a/tests/animation.js b/tests/animation.js index 7a8ae074..719ef972 100644 --- a/tests/animation.js +++ b/tests/animation.js @@ -49,7 +49,7 @@ const square = (color, w, h) => new Image(w, h).fill((x, y) => (x + y) % 5 === 0 if (still.getPixelAt(0, 0) !== 0xff0000ff) panic('APNG: the default image should be the first frame'); // still images decode to a single frame; GIFs stay GIFs - const single = await Animation.decode(await still.encode()); + const single = await Animation.decode(await still.encodePNG()); if (single.length !== 1 || single.width !== 8) panic('Animation.decode should accept still images'); const gif = await Animation.decode(await new GIF([Frame.from(still)]).encode()); if (!(gif instanceof Animation) || gif.length !== 1) panic('Animation.decode should decode GIFs'); diff --git a/tests/circle.js b/tests/circle.js index db96c174..87042f6d 100644 --- a/tests/circle.js +++ b/tests/circle.js @@ -12,7 +12,7 @@ const panic = message => { const image = new Image(512, 512); image.drawCircle(256, 256, 128, 0xffffffff); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/circle.png', encoded); @@ -25,7 +25,7 @@ const panic = message => { const image = new Image(512, 512); image.drawCircle(256, 256, 320, 0x000000ff); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/circle2.png', encoded); @@ -39,7 +39,7 @@ const panic = message => { image.fill(0x000000ff); image.cropCircle(); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/circle3.png', encoded); @@ -53,7 +53,7 @@ const panic = message => { image.fill(0x000000ff); image.cropCircle(true, .5); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/circle4.png', encoded); diff --git a/tests/crop.js b/tests/crop.js index e3f78352..ededbbba 100644 --- a/tests/crop.js +++ b/tests/crop.js @@ -7,7 +7,7 @@ const samePixels = require('./util/pixels'); image.fill((x, y) => Image.hslToColor((x + y) / (image.width + image.height), 1, .5)); image.crop(128, 128, 128, 128); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/crop.png', encoded); @@ -23,7 +23,7 @@ const samePixels = require('./util/pixels'); const source = new Image(512, 512).fill((x, y) => Image.hslToColor((x + y) / 1024, 1, .5)); const before = Buffer.from(source.bitmap); const region = source.region(128, 128, 128, 128); - if (!await samePixels(target, await region.encode(1, {creationTime: 0, software: ''}))) panic('region: wrong pixels'); + if (!await samePixels(target, await region.encodePNG(1, {creationTime: 0, software: ''}))) panic('region: wrong pixels'); if (!Buffer.from(source.bitmap).equals(before)) panic('region: changed the source'); region.fill(0); if (!Buffer.from(source.bitmap).equals(before)) panic('region: shares memory with the source'); diff --git a/tests/edge/smoke.mjs b/tests/edge/smoke.mjs index 638bd633..00900cd8 100644 --- a/tests/edge/smoke.mjs +++ b/tests/edge/smoke.mjs @@ -20,7 +20,7 @@ export async function smoke() { if (image.width !== photo.width || image.height !== photo.height) throw new Error(`decoded as ${image.width}x${image.height}`); return `${data.length} B`; }; - await step('png', async () => back(await alpha.encode())); + await step('png', async () => back(await alpha.encodePNG())); await step('jpeg', async () => back(await photo.encodeJPEG(80))); await step('webp lossless', async () => back(await alpha.encodeWebP())); await step('webp lossy', async () => back(await alpha.encodeWebP({ quality: 75 }))); diff --git a/tests/effects.js b/tests/effects.js index 27727952..616594cc 100644 --- a/tests/effects.js +++ b/tests/effects.js @@ -20,7 +20,7 @@ const samePixels = require('./util/pixels'); tiles.forEach((t, i) => image.composite(t, (i % 5) * 128, (i / 5 | 0) * 128)); image.composite(await Image.renderText(font, 56, 'Outlined', 0xffffffff, undefined, {strokeWidth: 5}), 8, 4 * 128 + 4); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/effects.png', encoded); diff --git a/tests/fill.js b/tests/fill.js index fa78f398..3f124ac6 100644 --- a/tests/fill.js +++ b/tests/fill.js @@ -12,7 +12,7 @@ const panic = message => { const image = new Image(512, 512); image.fill(0xff8000ff); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/fill-static.png', encoded); @@ -25,7 +25,7 @@ const panic = message => { const image = new Image(512, 512); image.fill((x, y) => Image.hslToColor((x + y) / (image.width + image.height), 1, .5)); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/fill-func.png', encoded); diff --git a/tests/fisheye.js b/tests/fisheye.js index 87732e92..1d59cd67 100644 --- a/tests/fisheye.js +++ b/tests/fisheye.js @@ -8,7 +8,7 @@ const samePixels = require('./util/pixels'); const image = await Image.decode(input); image.fisheye(); - const output = await image.encode(1, {creationTime: 0, software: ''}); + const output = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/fisheye.png', output); diff --git a/tests/flip.js b/tests/flip.js index acda5331..8734b431 100644 --- a/tests/flip.js +++ b/tests/flip.js @@ -9,7 +9,7 @@ const samePixels = require('./util/pixels'); const image = await Image.decode(input); image.flip('horizontal'); - const output = await image.encode(1, {creationTime: 0, software: ''}); + const output = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/flip-horizontal.png', output); @@ -24,7 +24,7 @@ const samePixels = require('./util/pixels'); const image = await Image.decode(input); image.flip('vertical'); - const output = await image.encode(1, {creationTime: 0, software: ''}); + const output = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/flip-vertical.png', output); diff --git a/tests/font.js b/tests/font.js index bdebc84e..afbec5e8 100644 --- a/tests/font.js +++ b/tests/font.js @@ -39,7 +39,7 @@ const panic = message => { { const font = await Image.renderText(await fs.readFile('./tests/fonts/carbon phyber.ttf'), 128, 'ThE qUiCk'); - const encoded = await font.encode(1, {creationTime: 0, software: ''}); + const encoded = await font.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/font-1.png', encoded); @@ -50,7 +50,7 @@ const panic = message => { { const font = await Image.renderText(await fs.readFile('./tests/fonts/ethnocentric rg.ttf'), 128, 'BrOwN fOx'); - const encoded = await font.encode(1, {creationTime: 0, software: ''}); + const encoded = await font.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/font-2.png', encoded); @@ -61,7 +61,7 @@ const panic = message => { { const font = await Image.renderText(await fs.readFile('./tests/fonts/opensans bold.ttf'), 128, 'Extra\n-Test\nnewline'); - const encoded = await font.encode(1, {creationTime: 0, software: ''}); + const encoded = await font.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/font-3.png', encoded); @@ -73,7 +73,7 @@ const panic = message => { { // OpenType font with CFF outlines const font = await Image.renderText(await fs.readFile('./tests/fonts/sourcesans3 regular.otf'), 64, 'Quartz glyph job, vex!'); - const encoded = await font.encode(1, {creationTime: 0, software: ''}); + const encoded = await font.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/font-4.png', encoded); @@ -248,7 +248,7 @@ const panic = message => { for (const [file, target] of [['noto color emoji subset.ttf', 'emoji-cbdt'], ['twemoji mozilla subset.ttf', 'emoji-colr'], ['noto colrv1 subset.ttf', 'emoji-colrv1']]) { const emoji = await fs.readFile(`./tests/fonts/${file}`); const image = await Image.renderText([sans, emoji], 48, 'Hi 😀🔥 👍🏽 🇩🇪 1️⃣ ❤️ 👨‍👩‍👧', {color: 0x000000ff}); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile(`./tests/targets/${target}.png`, encoded); if (!await samePixels(await fs.readFile(`./tests/targets/${target}.png`), encoded)) panic(`${target} doesn't match`); @@ -289,7 +289,7 @@ const panic = message => { const font = await fs.readFile('./tests/fonts/colrv1 paints.ttf'); const glyphs = Array.from({length: 17}, (_, k) => String.fromCodePoint(0xe000 + k)).join(''); const image = await Image.renderText(font, 48, glyphs, {color: 0x00aaaaff}); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/colrv1-paints.png', encoded); if (!await samePixels(await fs.readFile('./tests/targets/colrv1-paints.png'), encoded)) panic('colrv1-paints doesn\'t match'); @@ -374,7 +374,7 @@ const panic = message => { .map(name => fs.readFile(`./tests/fonts/${name}`))); const text = 'Hi שָׁלוֹם עוֹלָם (2024) and مرحبا بالعالم!\nمرحبا بالعالم، هذا نص (2024)!\nनमस्ते क्षत्रिय र्क्ष'; const font = await Image.renderText(fonts, 40, text, 0xffffffff, new TextLayout({maxWidth: 420, horizontalAlign: 'right'})); - const encoded = await font.encode(1, {creationTime: 0, software: ''}); + const encoded = await font.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/font-bidi.png', encoded); @@ -386,7 +386,7 @@ const panic = message => { const line = await Image.renderText(fonts, 40, 'مرحبا Hi', 0xffffffff); const arabic = await Image.renderText(fonts, 40, 'مرحبا', 0xffffffff); const right = line.clone().crop(line.width - arabic.width, 0, arabic.width, line.height); - if (!await samePixels(await arabic.encode(1, {creationTime: 0, software: ''}), await right.encode(1, {creationTime: 0, software: ''}))) + if (!await samePixels(await arabic.encodePNG(1, {creationTime: 0, software: ''}), await right.encodePNG(1, {creationTime: 0, software: ''}))) panic('the right to left paragraph should start on the right'); } diff --git a/tests/gainmap.js b/tests/gainmap.js index 74762208..3de304bb 100644 --- a/tests/gainmap.js +++ b/tests/gainmap.js @@ -303,5 +303,5 @@ const meanValue = (image, left, top, right, bottom) => { const removed = single.clone(); removed.gainMap = null; if ((await Image.decode(await removed.encodeJPEG())).gainMap !== null) panic('removed map still written'); - if ((await Image.decode(await single.encode())).gainMap !== null) panic('PNG with a gain map'); + if ((await Image.decode(await single.encodePNG())).gainMap !== null) panic('PNG with a gain map'); })(); diff --git a/tests/image.js b/tests/image.js index 36a19142..e0391338 100644 --- a/tests/image.js +++ b/tests/image.js @@ -6,7 +6,7 @@ module.exports = (async () => { const image = new Image(128, 128); image.fill(x => Image.hslToColor(x / image.width, 1, 0.5)); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/image.png', encoded); diff --git a/tests/invert.js b/tests/invert.js index 569997f3..bd672075 100644 --- a/tests/invert.js +++ b/tests/invert.js @@ -8,7 +8,7 @@ const samePixels = require('./util/pixels'); image.fill(0xff8000ff); image.invert(); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/invert.png', encoded); @@ -22,7 +22,7 @@ const samePixels = require('./util/pixels'); image.fill(0xff8000ff); image.invertLightness(); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/invert-lightness.png', encoded); @@ -36,7 +36,7 @@ const samePixels = require('./util/pixels'); image.fill((x, y) => Image.rgbToColor(x / 2, y / 2, 255 - x / 2)); image.invertValue(); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/invert-value.png', encoded); @@ -50,7 +50,7 @@ const samePixels = require('./util/pixels'); image.fill(0xff8000ff); image.hueShift(180); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/invert-hueshift.png', encoded); diff --git a/tests/metadata.js b/tests/metadata.js index e24cbbfb..16387a4b 100644 --- a/tests/metadata.js +++ b/tests/metadata.js @@ -15,7 +15,7 @@ const latin1 = (data, text) => Buffer.from(data).includes(Buffer.from(text, 'lat const animation = new Animation([Frame.from(image, 100), Frame.from(new Image(8, 4).fill(0x00ff00ff), 100)]); const encoders = { - png: m => image.encode(m), + png: m => image.encodePNG(m), jpeg: m => image.encodeJPEG({quality: 90, metadata: m}), webp: m => image.encodeWebP({metadata: m}), tga: m => image.encodeTGA({metadata: m}), @@ -41,7 +41,7 @@ const latin1 = (data, text) => Buffer.from(data).includes(Buffer.from(text, 'lat // PNG: tEXt is Latin-1, other text goes into iTXt (UTF-8), before the image data { - const png = Buffer.from(await image.encode(metadata)); + const png = Buffer.from(await image.encodePNG(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'); @@ -80,5 +80,5 @@ const latin1 = (data, text) => Buffer.from(data).includes(Buffer.from(text, 'lat 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'); + if (await image.encodePNG({creationTime: 'not a date'}).then(() => true, () => false)) panic('an invalid creation time should throw'); })(); diff --git a/tests/option-checks.js b/tests/option-checks.js index 26711025..8e4101d6 100644 --- a/tests/option-checks.js +++ b/tests/option-checks.js @@ -54,7 +54,7 @@ const rejects = async (name, fn, Type, pattern) => { await rejects('dominantColor threshold NaN', () => image.dominantColor({threshold: NaN}), RangeError, /threshold/); await rejects('renderSVG size NaN', () => Image.renderSVG('', {size: NaN}), RangeError, /SVG size/); await rejects('renderText size -1', () => Image.renderText(new Uint8Array(0), -1, 'x'), RangeError, /font size/); - const png = await image.encode(); + const png = await image.encodePNG(); for (const maxPixels of [NaN, -1, 0]) { await rejects(`Image.decode maxPixels ${maxPixels}`, () => Image.decode(png, {maxPixels}), RangeError, /maxPixels/); await rejects(`Animation.decode maxPixels ${maxPixels}`, () => Animation.decode(png, {maxPixels}), RangeError, /maxPixels/); diff --git a/tests/options.js b/tests/options.js index ea00bb25..8c9e660c 100644 --- a/tests/options.js +++ b/tests/options.js @@ -33,11 +33,11 @@ const same = (name, a, b) => { // encoders: decoded, since the metadata has a creation time const pixelsOf = async data => (await Image.decode(data)).bitmap; - same('encode', await pixelsOf(await tile().encode(9)), await pixelsOf(await tile().encode({compression: 9}))); + same('encode', await pixelsOf(await tile().encodePNG(9)), await pixelsOf(await tile().encodePNG({compression: 9}))); same('encodeJPEG', await pixelsOf(await tile().encodeJPEG(40)), await pixelsOf(await tile().encodeJPEG({quality: 40}))); // metadata as the first argument (3.x), and in the options const author = 'options test'; - for (const data of [await tile().encode({author}), await tile().encode({metadata: {author}}), await tile().encode(1, {author})]) + for (const data of [await tile().encodePNG({author}), await tile().encodePNG({metadata: {author}}), await tile().encodePNG(1, {author})]) if (!Buffer.from(data).includes(author)) panic('encode: metadata missing'); if (!Buffer.from(await tile().encodeJPEG({metadata: {author}})).includes(author)) panic('encodeJPEG: metadata missing'); for (const method of ['encodeWebP', 'encodeTGA']) diff --git a/tests/png.js b/tests/png.js index b0de4a45..d2b0949b 100644 --- a/tests/png.js +++ b/tests/png.js @@ -52,7 +52,7 @@ const expected = { // encode/decode roundtrip const image = new Image(97, 61); image.fill((x, y) => Image.rgbaToColor(x * 2, y * 4, (x * y) & 0xff, (x + y) & 0xff)); - const decoded = await Image.decode(await image.encode(3)); + const decoded = await Image.decode(await image.encodePNG(3)); if (!Buffer.from(decoded.bitmap).equals(Buffer.from(image.bitmap))) panic('roundtrip failed'); // a file cut off after its image data (in IEND) still decodes diff --git a/tests/readme.js b/tests/readme.js index 76f3ff2b..b81e4bd2 100644 --- a/tests/readme.js +++ b/tests/readme.js @@ -78,7 +78,7 @@ const font = name => fs.readFileSync('./tests/fonts/' + name); image.composite(greeting, left, card.y + 446 + (card.height - 446 - greeting.height) / 2); image.roundCorners(24); - const encoded = await image.encode({metadata: {creationTime: 0, software: ''}}); + const encoded = await image.encodePNG({metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) fs.writeFileSync('./tests/targets/readme.png', encoded); diff --git a/tests/regressions.js b/tests/regressions.js index 46239982..56a83b54 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -83,7 +83,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); // PNG metadata as the only argument { - const encoded = await new Image(1, 1).encode({title: 'regression'}); + const encoded = await new Image(1, 1).encodePNG({title: 'regression'}); assert(Buffer.from(encoded).includes(Buffer.from('regression')), 'encode(metadata): title missing'); } @@ -303,7 +303,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); rgba: [new Image(300, 2).fill((x, y) => Image.rgbaToColor(x & 255, x >> 8, y, 200)), 6] }; for (const [name, [image, type]] of Object.entries(cases)) { - const encoded = await image.encode(); + const encoded = await image.encodePNG(); const decoded = await Image.decode(encoded); assert(Buffer.from(decoded.bitmap).equals(Buffer.from(image.bitmap)), `PNG ${name}: roundtrip failed`); assert(encoded[25] === type, `PNG ${name}: expected color type ${type}, got ${encoded[25]}`); @@ -311,9 +311,9 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); // truecolor rows are filtered, which makes smooth gradients tiny const gradient = new Image(512, 512).fill((x, y) => Image.rgbToColor(x / 2, y / 2, (x + y) / 4)); - const size = (await gradient.encode()).length; + const size = (await gradient.encodePNG()).length; assert(size < 20000, `PNG: gradient should compress well with row filters, got ${size} bytes`); - assert(Buffer.from((await Image.decode(await gradient.encode(9))).bitmap).equals(Buffer.from(gradient.bitmap)), 'PNG: filtered roundtrip failed'); + assert(Buffer.from((await Image.decode(await gradient.encodePNG(9))).bitmap).equals(Buffer.from(gradient.bitmap)), 'PNG: filtered roundtrip failed'); } // invertValue inverts HSV value and invertLightness HSL lightness @@ -324,14 +324,6 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); assert(rgba(lightness, 0, 0).join() === '205,105,55,255', 'invertLightness should invert HSL lightness, got ' + rgba(lightness, 0, 0)); } - // encodePNG is an alias of encode - { - const image = new Image(3, 3).fill((x, y) => Image.rgbaToColor(x * 80, y * 80, 7, 200)); - const meta = {creationTime: 0, software: 'test'}; - assert(Buffer.from(await image.encodePNG(meta)).equals(Buffer.from(await image.encode(meta))), 'encodePNG(meta) should equal encode(meta)'); - assert(Buffer.from(await image.encodePNG(0, meta)).equals(Buffer.from(await image.encode(0, meta))), 'encodePNG(level, meta) should equal encode(level, meta)'); - } - // gif frame durations are rounded to centiseconds { // (different frames: identical ones are merged) @@ -436,15 +428,6 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); assert(coordinates[4].join() === '1,1' && pixels[4][2] === ColorHelpers.fromRGBA(50, 100, 7, 255), 'coordinates() and pixels() are row by row'); } - // encodePNG() is the implementation, encode() (deprecated, removed in 4.0) an alias of it - await withFrozenTime(async () => { - const image = new Image(5, 4).fill((x, y) => ColorHelpers.fromRGBA(x * 40, y * 60, 9, 200)); - for (const args of [[], [9], [0, {title: 'x'}], [{compression: 1}], [{title: 'y'}]]) { - const a = await image.encodePNG(...args), b = await image.encode(...args); - assert(Buffer.from(a).equals(Buffer.from(b)), `encode(${JSON.stringify(args)}) vs encodePNG`); - } - }); - // cropCircle({size}): 'contain' is the default, `max` (deprecated, removed in 4.0) maps to 'cover' { const wide = new Image(40, 20).fill(ColorHelpers.fromRGBA(200, 100, 50, 255)); @@ -764,7 +747,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); // type checks take any binary data { const {ImageType} = require('..'); - const png = await new Image(1, 1).encode(); + const png = await new Image(1, 1).encodePNG(); assert(ImageType.isPNG(png) && ImageType.isPNG(Buffer.from(png)) && ImageType.isPNG(new DataView(png.buffer)) && !ImageType.isGIF(png), 'ImageType checks'); } @@ -886,7 +869,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); }; const source = new Image(3, 2).fill((x, y) => Image.rgbaToColor(x * 100, y * 100, 0, 255)); - const encoded = Buffer.from(await source.encode()); + const encoded = Buffer.from(await source.encodePNG()); const withChunks = (...extra) => Buffer.concat([encoded.subarray(0, encoded.length - 12), ...extra, encoded.subarray(encoded.length - 12)]); for (const [name, make] of Object.entries(chunks)) { @@ -1078,7 +1061,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); cropCircle: v => image().cropCircle(false, v), opacity: v => image().opacity(v), red: v => image().red(v), lightness: v => image().lightness(v), composite: v => image().composite(image(), v, 0), hueShift: v => image().hueShift(v), brightness: v => image().brightness(v), gamma: v => image().gamma(v), colorToAlpha: v => image().colorToAlpha(v), rotate: v => image().rotate(v, false), gradient: v => Image.gradient({0: v, 1: 0}), - roundCorners: v => image().roundCorners(v), fisheye: v => image().fisheye(v), encode: v => image().encode(v), encodeJPEG: v => image().encodeJPEG(v), + roundCorners: v => image().roundCorners(v), fisheye: v => image().fisheye(v), encodePNG: v => image().encodePNG(v), encodeJPEG: v => image().encodeJPEG(v), frame: v => new Frame(4, 4, v), animation: v => new Animation([new Frame(4, 4)], v), }; for (const [name, call] of Object.entries(calls)) diff --git a/tests/resize.js b/tests/resize.js index ea90b769..a3e6ffe6 100644 --- a/tests/resize.js +++ b/tests/resize.js @@ -6,7 +6,7 @@ const samePixels = require('./util/pixels'); const image = await Image.decode(binary); image.resize({width: image.width / 4}); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/resize.png', encoded); diff --git a/tests/rotate.js b/tests/rotate.js index 1aeb197a..76c592f0 100644 --- a/tests/rotate.js +++ b/tests/rotate.js @@ -12,7 +12,7 @@ const panic = msg => { const image = await Image.decode(binary); image.rotate(45); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/rotate-45.png', encoded); @@ -25,7 +25,7 @@ const panic = msg => { const image = await Image.decode(binary); image.rotate(45, false); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/rotate-45-noresize.png', encoded); @@ -39,7 +39,7 @@ const panic = msg => { const image = await Image.decode(binary); image.rotate(90); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/rotate-90.png', encoded); @@ -52,7 +52,7 @@ const panic = msg => { const image = await Image.decode(binary); image.rotate(180); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/rotate-180.png', encoded); @@ -65,7 +65,7 @@ const panic = msg => { const image = await Image.decode(binary); image.rotate(270); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/rotate-270.png', encoded); diff --git a/tests/svg-images.js b/tests/svg-images.js index 93a68217..e17b54be 100644 --- a/tests/svg-images.js +++ b/tests/svg-images.js @@ -47,11 +47,11 @@ const crc32 = bytes => { const logo = new Image(4, 3).fill((x, y) => Image.rgbaToColor(x * 60, y * 80, 128, x === 0 ? 0 : 255)); const other = new Image(4, 3).fill((x, y) => Image.rgbaToColor(255 - x * 60, y * 40, 200, 255)); const opaque = new Image(4, 3).fill((x, y) => Image.rgbaToColor(x * 60, y * 80, 128, 255)); - const png = await logo.encode(1); + const png = await logo.encodePNG(1); const webp = await logo.encodeWebP(); const jpeg = await opaque.encodeJPEG(90); const gif = await new Animation([Frame.from(opaque)]).encodeGIF(); - const otherPNG = await other.encode(1); + const otherPNG = await other.encodePNG(1); // images given by href render exactly like the same images as data URIs, in every format and placement const formats = [['image/png', png], ['image/webp', webp], ['image/jpeg', jpeg], ['image/gif', gif]]; @@ -152,7 +152,7 @@ const crc32 = bytes => { // a huge declared image doesn't get around the decoding limit: it draws nothing, like a broken image { - const tiny = await new Image(1, 1).fill(0xffffffff).encode(1); + const tiny = await new Image(1, 1).fill(0xffffffff).encodePNG(1); const huge = Uint8Array.from(tiny); const view = new DataView(huge.buffer); view.setUint32(16, 20000); @@ -165,7 +165,7 @@ const crc32 = bytes => { // quantized images can be given too (the palette of an SVG's image) { - const reduced = await new Image(4, 3).fill((x, y) => Image.rgbaToColor(x * 60, y * 80, 128, 255)).quantize({colors: 4}).encode(1); + const reduced = await new Image(4, 3).fill((x, y) => Image.rgbaToColor(x * 60, y * 80, 128, 255)).quantize({colors: 4}).encodePNG(1); const result = await Image.renderSVG(svg(''), {images: {'logo.png': reduced}}); if (transparent(result)) panic('a reduced image was not drawn'); } diff --git a/tests/svg.js b/tests/svg.js index 9470486a..7f876968 100644 --- a/tests/svg.js +++ b/tests/svg.js @@ -11,7 +11,7 @@ const panic = message => { { const svg = await fs.readFile('./tests/svgs/potato.svg'); const image = await Image.renderSVG(svg.toString(), 256 / 36, SVGScaleModes.SCALE); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/potato.png', encoded); @@ -26,7 +26,7 @@ const panic = message => { const image = await Image.renderSVG(svg, 480, SVGScaleModes.WIDTH); if (image.width !== 480 || image.height !== 320) panic('features: unexpected size'); - const encoded = await image.encode(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/svg-features.png', encoded); @@ -53,7 +53,7 @@ const panic = message => { const gif = await new Animation([Frame.from(opaque)]).encodeGIF(); const jpeg = await opaque.encodeJPEG(90); const cases = [ - ['image/png', await source.encode(1), source], + ['image/png', await source.encodePNG(1), source], ['image/webp', await source.encodeWebP(), source], ['image/gif', gif, (await Animation.decode(gif))[0]], ['image/jpeg', jpeg, await Image.decode(jpeg)], diff --git a/tests/util/without-native.js b/tests/util/without-native.js index f43ccff0..a51b2a5e 100644 --- a/tests/util/without-native.js +++ b/tests/util/without-native.js @@ -22,7 +22,7 @@ if (mode === 'plain') { const image = await Image.decode(fs.readFileSync('./tests/targets/image.png')); results.decoded = sha256(image.bitmap); for (let level = 0; level <= 9; level++) { - const encoded = await image.encode({compression: level, metadata: {creationTime: 0, software: ''}}); + const encoded = await image.encodePNG({compression: level, metadata: {creationTime: 0, software: ''}}); // the output differs between the deflaters, the pixels must not results[`level ${level}`] = sha256((await Image.decode(encoded)).bitmap); } From e01dcc77ee9915445709d896dc957cc62289eef2 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 16:13:12 +0000 Subject: [PATCH 004/188] remove the color helpers of Image --- CHANGELOG.md | 2 + src/image.ts | 97 +------------------------------------ tests/animation-encoder.js | 4 +- tests/animation-optimize.js | 18 +++---- tests/animation.js | 4 +- tests/avif-encode.js | 8 +-- tests/bmp.js | 10 ++-- tests/browser/page.mjs | 4 +- tests/color-matrix.js | 4 +- tests/crop.js | 6 +-- tests/effects.js | 4 +- tests/fill.js | 4 +- tests/gainmap.js | 4 +- tests/gif.js | 8 +-- tests/image.js | 4 +- tests/invert.js | 4 +- tests/jxl.js | 4 +- tests/misc.js | 16 +++--- tests/palette.js | 10 ++-- tests/png.js | 4 +- tests/pnm.js | 10 ++-- tests/qoi.js | 4 +- tests/readme.js | 4 +- tests/regressions.js | 90 ++++++++++++++++------------------ tests/rotate.js | 4 +- tests/svg-images.js | 10 ++-- tests/svg.js | 4 +- tests/tga.js | 4 +- tests/transform.js | 4 +- tests/webp-lossy.js | 10 ++-- tests/webp.js | 6 +-- 31 files changed, 135 insertions(+), 234 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f174a1b..75413dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - **`Image.encode()` is removed**: use `image.encodePNG()`, which takes the same arguments. +- **The color helpers of `Image` are removed**: `Image.rgbaToColor()`, `Image.rgbToColor()`, `Image.hslaToColor()`, `Image.hslToColor()`, `Image.rgbaToHSLA()`, `Image.colorToRGBA()`, `Image.colorToRGB()` and `Image.gradient()` are now `Colors.fromRGBA()`, `Colors.fromRGB()`, `Colors.fromHSLA()`, `Colors.fromHSL()`, `Colors.toHSLA()`, `Colors.toRGBA()`, `Colors.toRGB()` and `Colors.gradient()`. + ## 3.20.1 ### Performance diff --git a/src/image.ts b/src/image.ts index 10c054e6..a372f168 100644 --- a/src/image.ts +++ b/src/image.ts @@ -32,7 +32,7 @@ import { encode as utf8 } from './util/utf8.ts'; import { resample, BOX, TRIANGLE, CUBIC, type Region } from './util/resample.ts'; import { rotateBilinear } from './util/rotate.ts'; import { gaussianBlur, boxBlurRGBA, premultiply, unpremultiply } from './util/blur.ts'; -import { Colors, wrapHue, hue2rgb, rgbaToHSLA } from './colors.ts'; +import { Colors, wrapHue, hue2rgb } from './colors.ts'; import { isOptions, finite, color as checkColor, quality as checkQuality, effort as checkEffort, compressionLevel } from './util/options.ts'; import { MAX_PIXELS, decoding } from './util/limits.ts'; import { GainMap } from './gainmap.ts'; @@ -794,89 +794,6 @@ export class Image { yield* this.pixels(); } - /** - * Converts RGBA components to an RGBA value, values are rounded and clamped to 0..255 - * @param r red (0..255) - * @param g green (0..255) - * @param b blue (0..255) - * @param a alpha (0..255) - * @deprecated Use {@link Colors.fromRGBA}, `Image.rgbaToColor` will be removed in 4.0 - * @group Deprecated - */ - static rgbaToColor(r: number, g: number, b: number, a: number): Color { - return Colors.fromRGBA(r, g, b, a); - } - - /** - * Converts RGB components to an RGBA value (assuming alpha = 255) - * @param r red (0..255) - * @param g green (0..255) - * @param b blue (0..255) - * @deprecated Use {@link Colors.fromRGB}, `Image.rgbToColor` will be removed in 4.0 - * @group Deprecated - */ - static rgbToColor(r: number, g: number, b: number): Color { - return Colors.fromRGB(r, g, b); - } - - /** - * Converts HSLA colors to RGBA colors - * @param h hue (0..1) - * @param s saturation (0..1) - * @param l lightness (0..1) - * @param a opacity (0..1) - * @deprecated Use {@link Colors.fromHSLA}, `Image.hslaToColor` will be removed in 4.0 - * @group Deprecated - */ - static hslaToColor(h: number, s: number, l: number, a: number): Color { - return Colors.fromHSLA(h, s, l, a); - } - - /** - * Converts HSL colors to RGBA colors (assuming an opacity of 255) - * @param h hue (0..1) - * @param s saturation (0..1) - * @param l lightness (0..1) - * @deprecated Use {@link Colors.fromHSL}, `Image.hslToColor` will be removed in 4.0 - * @group Deprecated - */ - static hslToColor(h: number, s: number, l: number): Color { - return Colors.fromHSL(h, s, l); - } - - /** - * Converts an RGBA value to an array of HSLA values - * @param r red (0..255) - * @param g green (0..255) - * @param b blue (0..255) - * @param a alpha (0..255) - * @deprecated Use {@link Colors.toHSLA}, `Image.rgbaToHSLA` will be removed in 4.0 - * @group Deprecated - */ - static rgbaToHSLA(r: number, g: number, b: number, a: number): [h: number, s: number, l: number, a: number] { - return rgbaToHSLA(r, g, b, a); - } - - /** - * Converts a color value to an array of RGBA values - * @param color The color value to convert - * @deprecated Use {@link Colors.toRGBA}, `Image.colorToRGBA` will be removed in 4.0 - * @group Deprecated - */ - static colorToRGBA(color: Color): [r: number, g: number, b: number, a: number] { - return Colors.toRGBA(color); - } - - /** - * Converts a color value to an array of RGB values (ignoring the colors alpha) - * @param color The color value to convert - * @deprecated Use {@link Colors.toRGB}, `Image.colorToRGB` will be removed in 4.0 - * @group Deprecated - */ - static colorToRGB(color: Color): [r: number, g: number, b: number] { - return Colors.toRGB(color); - } - /** * Creates an image with a linear gradient, like CSS' `linear-gradient()` * @param width The image's width @@ -921,16 +838,6 @@ export class Image { return image; } - /** - * Creates a multi-point gradient generator - * @param colors The gradient points to use (e.g. `{0: 0xff0000ff, 1: 0x00ff00ff}`) - * @deprecated Use {@link Colors.gradient}, `Image.gradient` will be removed in 4.0 - * @group Deprecated - */ - static gradient(colors: Record): (position: number) => Color { - return Colors.gradient(colors); - } - /** * Gets the pixel color at the specified position (0-based) * @param x The x coordinate @@ -1963,7 +1870,7 @@ export class Image { hsla.a = bitmap[i + 3] / 255; map(hsla); - // see hslaToColor + // see Colors.fromHSLA const nh = wrapHue(hsla.h); const ns = Math.min(1, Math.max(0, hsla.s)); const nl = Math.min(1, Math.max(0, hsla.l)); diff --git a/tests/animation-encoder.js b/tests/animation-encoder.js index 5ab0cacf..c8997a9c 100644 --- a/tests/animation-encoder.js +++ b/tests/animation-encoder.js @@ -1,5 +1,5 @@ const fs = require('fs').promises; -const {Image, Frame, Animation} = require('..'); +const {Image, Frame, Animation, Colors} = require('..'); const panic = message => { console.error(message); @@ -14,7 +14,7 @@ const metadata = {creationTime: 0, software: ''}; const frames = []; for (let i = 0; i < 6; i++) { const f = new Frame(20 + i, 15, {duration: 50 + i * 10, xOffset: i * 3, yOffset: i, disposalMode: ['keep', 'previous', 'background', 'keep', 'any', 'background'][i]}); - f.fill((x, y) => Image.rgbaToColor((x * 30 + i * 40) & 255, y * 15, i * 40, (x + y + i) % 5 ? 255 : 100)); + f.fill((x, y) => Colors.fromRGBA((x * 30 + i * 40) & 255, y * 15, i * 40, (x + y + i) % 5 ? 255 : 100)); frames.push(f); } // offsets, every disposal, translucent pixels; a decoded GIF diff --git a/tests/animation-optimize.js b/tests/animation-optimize.js index 3f61d583..658b365d 100644 --- a/tests/animation-optimize.js +++ b/tests/animation-optimize.js @@ -1,4 +1,4 @@ -const {Image, Frame, Animation} = require('..'); +const {Image, Frame, Animation, Colors} = require('..'); const panic = message => { console.error(message); @@ -107,10 +107,10 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { // an opaque animation whose frames are smaller sparse, but not by enough to pay for an alpha channel in every APNG // frame: they're filled in again { - const background = new Image(40, 20).fill((x, y) => Image.rgbaToColor(y * 6, 100, 200 - y * 4, 255)); + const background = new Image(40, 20).fill((x, y) => Colors.fromRGBA(y * 6, 100, 200 - y * 4, 255)); const animation = new Animation(Array.from({length: 6}, (_, i) => Frame.from(background.clone() - .drawBox(1, 1, 6, 6, Image.rgbaToColor(255, i * 40, 0, 255)) - .drawBox(32, 13, 6, 6, Image.rgbaToColor(0, 255, i * 40, 255)), 100))); + .drawBox(1, 1, 6, 6, Colors.fromRGBA(255, i * 40, 0, 255)) + .drawBox(32, 13, 6, 6, Colors.fromRGBA(0, 255, i * 40, 255)), 100))); await check('opaque animation', animation); // with transparency for free, the frames are sparse; with an alpha channel that costs as much as RGBA instead of RGB // (APNG), they're filled in @@ -121,7 +121,7 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { // a GIF frame stays dense when its colors fill the palette and its sparse pixels wouldn't fit (they'd need one more // color for the unchanged pixels): it stays lossless. The blocks around the center change color, the center doesn't { - const color = index => Image.rgbaToColor(index, 255 - index, index * 37 & 255, 255); + const color = index => Colors.fromRGBA(index, 255 - index, index * 37 & 255, 255); // 24×24 blocks: the ring around the center has all 256 colors in turn, the center some of them const ring = (i, j) => i < 4 || i > 19 || j < 4 || j > 19; const grid = shift => { @@ -151,7 +151,7 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { // a mostly static animation (like a screen recording) only stores what changes { - const background = new Image(160, 120).fill((x, y) => Image.rgbaToColor(x * 7 ^ y * 13, x * y, y * 5, 255)); + const background = new Image(160, 120).fill((x, y) => Colors.fromRGBA(x * 7 ^ y * 13, x * y, y * 5, 255)); const animation = new Animation(Array.from({length: 20}, (_, i) => Frame.from(background.clone().drawBox(10 + i * 6, 50, 8, 8, 0xffffffff), 50))); await check('screen recording', animation); @@ -171,12 +171,12 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { const random = () => (seed = Math.imul(seed, 1103515245) + 12345 >>> 0) / 2 ** 32; const map = new Image(100, 60).fill((x, y) => { const g = 150 + 40 * Math.sin(x / 9) * Math.cos(y / 13) + (random() - 0.5) * 30; - return Image.rgbaToColor(g | 0, g * 0.9 | 0, g * 0.8 | 0, 255); + return Colors.fromRGBA(g | 0, g * 0.9 | 0, g * 0.8 | 0, 255); }); const animation = new Animation(Array.from({length: 8}, (_, i) => { - const layer = new Image(100, 60).fill(x => x < 80 ? Image.rgbaToColor(i % 4, 180 + i * 7 % 5, 250 + i * 3 % 4, 150) : 0); + const layer = new Image(100, 60).fill(x => x < 80 ? Colors.fromRGBA(i % 4, 180 + i * 7 % 5, 250 + i * 3 % 4, 150) : 0); return Frame.from(map.clone().composite(layer).drawBox(5 + i * 10, 20, 12, 12, 0xff0000ff) - .drawBox(90, 50, 6, 6, Image.rgbaToColor(0, i * 30, 0, 255)), 50); + .drawBox(90, 50, 6, 6, Colors.fromRGBA(0, i * 30, 0, 255)), 50); })); const tolerance = 3; diff --git a/tests/animation.js b/tests/animation.js index 719ef972..32461a16 100644 --- a/tests/animation.js +++ b/tests/animation.js @@ -1,4 +1,4 @@ -const {Image, Frame, Animation, GIF} = require('..'); +const {Image, Frame, Animation, GIF, Colors} = require('..'); const panic = message => { console.error(message); @@ -24,7 +24,7 @@ const square = (color, w, h) => new Image(w, h).fill((x, y) => (x + y) % 5 === 0 (async () => { for (const variant of [0, 1]) { const animation = new Animation([ - Frame.from(new Image(40, 30).fill((x, y) => Image.rgbaToColor(x * 6, y * 8, 128, 255)), 100), + Frame.from(new Image(40, 30).fill((x, y) => Colors.fromRGBA(x * 6, y * 8, 128, 255)), 100), Frame.from(square(0xff2040ff, 10, 8), 50, variant ? 3 : 4, variant ? 5 : 6, 'background'), Frame.from(square(0x20ff40ff, 12, 10), 70, 10, 10, variant ? 'previous' : 'keep'), Frame.from(square(0xc8374fff, 8, 8), 30, 20, 2, 'keep') diff --git a/tests/avif-encode.js b/tests/avif-encode.js index ce1a85f0..cf4267b6 100644 --- a/tests/avif-encode.js +++ b/tests/avif-encode.js @@ -1,5 +1,5 @@ const fs = require('fs').promises; -const {Image} = require('..'); +const {Image, Colors} = require('..'); const panic = message => { console.error(message); @@ -60,7 +60,7 @@ const compare = (a, b) => { const screen = new Image(96, 80); for (let y = 0; y < 80; y++) for (let x = 0; x < 96; x++) { const c = (x >> 3 ^ y >> 2) % 5 === 0 ? [20, 30, 200] : (x * x + y * 3) % 7 < 2 ? [240, 240, 240] : x < 48 ? [200, 40, 40] : [30, 160, 60]; - screen.setPixelAt(x, y, Image.rgbaToColor(c[0], c[1], c[2], 255)); + screen.setPixelAt(x, y, Colors.fromRGBA(c[0], c[1], c[2], 255)); } for (const effort of [0, 1]) { const back = await Image.decode(await screen.encodeAVIF({quality: 80, effort})); @@ -75,7 +75,7 @@ const compare = (a, b) => { const big = photo.clone().resize({width: 448}); for (let i = 3; i < big.bitmap.length; i += 4) big.bitmap[i] = (i >> 2) % big.width; const wide = new Image(4160, 128); - for (let y = 0; y < 128; y++) for (let x = 0; x < 4160; x++) wide.setPixelAt(x, y, Image.rgbaToColor(x * 37 & 255, y * 71 & 255, (x ^ y) & 255, 255)); + for (let y = 0; y < 128; y++) for (let x = 0; x < 4160; x++) wide.setPixelAt(x, y, Colors.fromRGBA(x * 37 & 255, y * 71 & 255, (x ^ y) & 255, 255)); for (const [name, image, effort] of [['photo with alpha', big, 1], ['tiles', wide, 0]]) { const one = await image.encodeAVIF({effort, threads: 1, metadata: {creationTime: 0}}); const four = await image.encodeAVIF({effort, threads: 4, metadata: {creationTime: 0}}); @@ -87,7 +87,7 @@ const compare = (a, b) => { // odd sizes, a pixel, wider than a tile (4096 pixels) for (const [w, h] of [[1, 1], [7, 3], [65, 130], [4100, 8]]) { const image = new Image(w, h); - for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) image.setPixelAt(x, y, Image.rgbaToColor(x * 37 & 255, y * 71 & 255, (x + y) * 5 & 255, 255)); + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) image.setPixelAt(x, y, Colors.fromRGBA(x * 37 & 255, y * 71 & 255, (x + y) * 5 & 255, 255)); const back = await Image.decode(await image.encodeAVIF({quality: 90, effort: 0})); if (back.width !== w || back.height !== h || compare(image, back)[0] < 28) panic(`${w}x${h}: ${compare(image, back)[0].toFixed(2)} dB`); } diff --git a/tests/bmp.js b/tests/bmp.js index 88a501a9..110e3a90 100644 --- a/tests/bmp.js +++ b/tests/bmp.js @@ -1,6 +1,6 @@ const fs = require('fs').promises; const crypto = require('crypto'); -const {Image, ImageType} = require('..'); +const {Image, ImageType, Colors} = require('..'); const panic = message => { console.error(message); @@ -63,10 +63,10 @@ const expected = { // encoding: the smallest bit depth that holds the image, alpha with a version 4 header const cases = [ [new Image(13, 5).fill((x, y) => (x + y) & 1 ? 0xffffffff : 0x000000ff), 1], - [new Image(17, 6).fill((x, y) => Image.rgbaToColor(x % 4 * 80, y % 4 * 80, 40, 255)), 4], - [new Image(33, 9).fill((x, y) => Image.rgbaToColor(x * 7, y % 7 * 30, 99, 255)), 8], - [new Image(31, 9).fill((x, y) => Image.rgbaToColor(x * 8, y * 28, (x * 13 + y * 7) & 255, 255)), 24], - [new Image(29, 6).fill((x, y) => Image.rgbaToColor(x * 8, 200 - y * 30, x * y, (x * 9) & 255)), 32], + [new Image(17, 6).fill((x, y) => Colors.fromRGBA(x % 4 * 80, y % 4 * 80, 40, 255)), 4], + [new Image(33, 9).fill((x, y) => Colors.fromRGBA(x * 7, y % 7 * 30, 99, 255)), 8], + [new Image(31, 9).fill((x, y) => Colors.fromRGBA(x * 8, y * 28, (x * 13 + y * 7) & 255, 255)), 24], + [new Image(29, 6).fill((x, y) => Colors.fromRGBA(x * 8, 200 - y * 30, x * y, (x * 9) & 255)), 32], [new Image(1, 1).fill(0x123456ff), 1], ]; for (const [image, bits] of cases) { diff --git a/tests/browser/page.mjs b/tests/browser/page.mjs index ec45bbe5..c8dd909c 100644 --- a/tests/browser/page.mjs +++ b/tests/browser/page.mjs @@ -1,6 +1,6 @@ // the browser test's page script: AVIF and JXL with and without threads (the same files and pixels), the codecs' smoke // test -import { Image } from 'imagescript'; +import { Image, Colors } from 'imagescript'; import { smoke } from '../edge/smoke.mjs'; const digest = async data => [...new Uint8Array(await crypto.subtle.digest('SHA-256', data))].map(b => b.toString(16).padStart(2, '0')).join(''); @@ -8,7 +8,7 @@ const digest = async data => [...new Uint8Array(await crypto.subtle.digest('SHA- const results = { isolated: self.crossOriginIsolated }; try { const image = new Image(640, 512); - for (let y = 0; y < 512; y++) for (let x = 0; x < 640; x++) image.setPixelAt(x, y, Image.rgbaToColor(x * 3 & 255, y * 5 & 255, (x ^ y) & 255, 255)); + for (let y = 0; y < 512; y++) for (let x = 0; x < 640; x++) image.setPixelAt(x, y, Colors.fromRGBA(x * 3 & 255, y * 5 & 255, (x ^ y) & 255, 255)); results.single = await digest(await image.encodeAVIF({ effort: 1, threads: 1, metadata: { creationTime: 0 } })); results.threaded = await digest(await image.encodeAVIF({ effort: 1, threads: 4, metadata: { creationTime: 0 } })); results.jxlSingle = await digest(await image.encodeJXL({ quality: 85, threads: 1, metadata: { creationTime: 0 } })); diff --git a/tests/color-matrix.js b/tests/color-matrix.js index 6d5af9cc..d3be46a6 100644 --- a/tests/color-matrix.js +++ b/tests/color-matrix.js @@ -1,4 +1,4 @@ -const {Image, Frame, GainMap} = require('..'); +const {Image, Frame, GainMap, Colors} = require('..'); const panic = message => { console.error(message); @@ -15,7 +15,7 @@ const throwsA = (fn, type, label) => { const px = (image, x, y) => Array.from(image.bitmap.subarray((y * image.width + x) * 4, (y * image.width + x) * 4 + 4)); const same = (a, b) => a.width === b.width && a.height === b.height && Buffer.from(a.bitmap).equals(Buffer.from(b.bitmap)); // an opaque image where every pixel is different -const photo = (width, height) => new Image(width, height).fill((x, y) => Image.rgbaToColor((x * 37 + y * 11) % 256, (x * 5 + y * 91) % 256, (x * y + x) % 256, 255)); +const photo = (width, height) => new Image(width, height).fill((x, y) => Colors.fromRGBA((x * 37 + y * 11) % 256, (x * 5 + y * 91) % 256, (x * y + x) % 256, 255)); const IDENTITY = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0]; (async () => { diff --git a/tests/crop.js b/tests/crop.js index ededbbba..5a7b3331 100644 --- a/tests/crop.js +++ b/tests/crop.js @@ -1,10 +1,10 @@ const fs = require('fs').promises; -const {Image, Frame} = require('..'); +const {Image, Frame, Colors} = require('..'); const samePixels = require('./util/pixels'); (async () => { const image = new Image(512, 512); - image.fill((x, y) => Image.hslToColor((x + y) / (image.width + image.height), 1, .5)); + image.fill((x, y) => Colors.fromHSL((x + y) / (image.width + image.height), 1, .5)); image.crop(128, 128, 128, 128); const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); @@ -20,7 +20,7 @@ const samePixels = require('./util/pixels'); console.error(message); process.exit(1); }; - const source = new Image(512, 512).fill((x, y) => Image.hslToColor((x + y) / 1024, 1, .5)); + const source = new Image(512, 512).fill((x, y) => Colors.fromHSL((x + y) / 1024, 1, .5)); const before = Buffer.from(source.bitmap); const region = source.region(128, 128, 128, 128); if (!await samePixels(target, await region.encodePNG(1, {creationTime: 0, software: ''}))) panic('region: wrong pixels'); diff --git a/tests/effects.js b/tests/effects.js index 616594cc..e14c9531 100644 --- a/tests/effects.js +++ b/tests/effects.js @@ -1,11 +1,11 @@ const fs = require('fs').promises; -const {Image} = require('..'); +const {Image, Colors} = require('..'); const samePixels = require('./util/pixels'); (async () => { const photo = await Image.decode(await fs.readFile('./tests/targets/external.jpg')); const tile = () => photo.clone().resize(128, 128); - const gradient = new Image(128, 128).fill((x, y) => Image.rgbToColor(x * 2, 255 - y * 2, 140)); + const gradient = new Image(128, 128).fill((x, y) => Colors.fromRGB(x * 2, 255 - y * 2, 140)); const font = await fs.readFile('./tests/fonts/opensans bold.ttf'); const tiles = [ diff --git a/tests/fill.js b/tests/fill.js index 3f124ac6..829eff72 100644 --- a/tests/fill.js +++ b/tests/fill.js @@ -1,5 +1,5 @@ const fs = require('fs').promises; -const {Image} = require('..'); +const {Image, Colors} = require('..'); const samePixels = require('./util/pixels'); const panic = message => { @@ -23,7 +23,7 @@ const panic = message => { { const image = new Image(512, 512); - image.fill((x, y) => Image.hslToColor((x + y) / (image.width + image.height), 1, .5)); + image.fill((x, y) => Colors.fromHSL((x + y) / (image.width + image.height), 1, .5)); const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); diff --git a/tests/gainmap.js b/tests/gainmap.js index 3de304bb..bc4ffa8b 100644 --- a/tests/gainmap.js +++ b/tests/gainmap.js @@ -1,5 +1,5 @@ const fs = require('fs').promises; -const {Image, GainMap} = require('..'); +const {Image, GainMap, Colors} = require('..'); const panic = message => { console.error(message); @@ -90,7 +90,7 @@ const meanValue = (image, left, top, right, bottom) => { // made by hand: parameters checked, written and read back; per channel values { const image = new Image(64, 48).fill(0x808080ff); - image.gainMap = new GainMap(new Image(16, 12).fill((x, y) => Image.rgbaToColor(x * 16, y * 20, 255 - x * 16, 255)), {gainMapMax: [2, 3, 4], gainMapMin: -1, gamma: 2}); + image.gainMap = new GainMap(new Image(16, 12).fill((x, y) => Colors.fromRGBA(x * 16, y * 20, 255 - x * 16, 255)), {gainMapMax: [2, 3, 4], gainMapMin: -1, gamma: 2}); const back = await Image.decode(await image.encodeJPEG(95)); const map = back.gainMap; if (!map || map.channels !== 3 || !close(map.gainMapMax, [2, 3, 4]) || !close(map.gainMapMin, [-1, -1, -1]) || !close(map.gamma, [2, 2, 2]) || map.hdrCapacityMax !== 4 || map.width !== 16) diff --git a/tests/gif.js b/tests/gif.js index a0a4064f..ecf7f910 100644 --- a/tests/gif.js +++ b/tests/gif.js @@ -1,6 +1,6 @@ const fs = require('fs').promises; const crypto = require('crypto'); -const {Frame, FrameDisposalModes, GIF} = require('..'); +const {Frame, FrameDisposalModes, GIF, Colors} = require('..'); const panic = message => { console.error(message); @@ -11,7 +11,7 @@ const panic = message => { const frames = []; for (let i = 0; i < 30; i++) { const frame = new Frame(256, 256); - frame.fill(x => Frame.hslToColor(x / frame.width + i / 30, 1, 0.5)); + frame.fill(x => Colors.fromHSL(x / frame.width + i / 30, 1, 0.5)); frames.push(frame); } @@ -54,8 +54,8 @@ const panic = message => { { // offsets, transparency, disposal modes and finite loop counts - const black = Frame.rgbaToColor(0, 0, 0, 255); - const white = Frame.rgbaToColor(255, 255, 255, 255); + const black = Colors.fromRGBA(0, 0, 0, 255); + const white = Colors.fromRGBA(255, 255, 255, 255); const background = new Frame(4, 4, 100); background.fill(black); diff --git a/tests/image.js b/tests/image.js index e0391338..c2c119f3 100644 --- a/tests/image.js +++ b/tests/image.js @@ -1,10 +1,10 @@ const fs = require('fs').promises; -const {Image} = require('..'); +const {Image, Colors} = require('..'); const samePixels = require('./util/pixels'); module.exports = (async () => { const image = new Image(128, 128); - image.fill(x => Image.hslToColor(x / image.width, 1, 0.5)); + image.fill(x => Colors.fromHSL(x / image.width, 1, 0.5)); const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); diff --git a/tests/invert.js b/tests/invert.js index bd672075..65863c2a 100644 --- a/tests/invert.js +++ b/tests/invert.js @@ -1,5 +1,5 @@ const fs = require('fs').promises; -const {Image} = require('..'); +const {Image, Colors} = require('..'); const samePixels = require('./util/pixels'); (async () => { @@ -33,7 +33,7 @@ const samePixels = require('./util/pixels'); { const image = new Image(512, 512); - image.fill((x, y) => Image.rgbToColor(x / 2, y / 2, 255 - x / 2)); + image.fill((x, y) => Colors.fromRGB(x / 2, y / 2, 255 - x / 2)); image.invertValue(); const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); diff --git a/tests/jxl.js b/tests/jxl.js index 126cf02c..05193578 100644 --- a/tests/jxl.js +++ b/tests/jxl.js @@ -1,5 +1,5 @@ const fs = require('fs').promises; -const {Image, ImageType, Animation} = require('..'); +const {Image, ImageType, Animation, Colors} = require('..'); const panic = message => { console.error(message); @@ -67,7 +67,7 @@ const difference = (bitmap, width, height, reference, step = 1, premultiplied = // threads (groups, Gaborish, EPF and the XYB conversion in parallel, from 4 megapixels): the same pixels as without, // lossy (with alpha) and lossless const big = new Image(2304, 2048); - for (let y = 0; y < big.height; y++) for (let x = 0; x < big.width; x++) big.setPixelAt(x, y, Image.rgbaToColor(x * 7 & 255, (x ^ y) & 255, y * 3 & 255, x < 64 ? 128 : 255)); + for (let y = 0; y < big.height; y++) for (let x = 0; x < big.width; x++) big.setPixelAt(x, y, Colors.fromRGBA(x * 7 & 255, (x ^ y) & 255, y * 3 & 255, x < 64 ? 128 : 255)); for (const options of [{quality: 80, effort: 0}, {effort: 0}]) { const data = await big.encodeJXL(options); const one = await Image.decode(data, {threads: 1}), four = await Image.decode(data, {threads: 4}); diff --git a/tests/misc.js b/tests/misc.js index 16c77d1a..f475f8af 100644 --- a/tests/misc.js +++ b/tests/misc.js @@ -1,4 +1,4 @@ -const {Image, Frame, GIF} = require('..'); +const {Image, Frame, GIF, Colors} = require('..'); const panic = msg => { console.error(msg); process.exit(1); @@ -25,24 +25,24 @@ for (const [x, y] of new Image(1, 1)) for (const [x, y, color] of new Image(1, 1).fill(0xff8000ff).iterateWithColors()) if (x !== 0 || y !== 0 || color !== 0xff8000ff) panic('iterateWithColors failed'); -if (Image.rgbToColor(0xff, 0x80, 0x00) !== 0xff8000ff) +if (Colors.fromRGB(0xff, 0x80, 0x00) !== 0xff8000ff) panic('rgbToColor failed'); -if (Image.hslaToColor(0, 0, 1, 0xff) !== 0xffffffff) +if (Colors.fromHSLA(0, 0, 1, 0xff) !== 0xffffffff) panic('hslaToColor for s=0 failed'); { - for (const rgba of [0xff0000ff, 0x00ff00ff, 0x0000ffff, 0x000000ff, 0xffffffff].map(x => Image.colorToRGBA(x))) { - const hsla = Image.rgbaToHSLA(...rgba); - const rgbaBacktransform = Image.colorToRGBA(Image.hslaToColor(...hsla)); + for (const rgba of [0xff0000ff, 0x00ff00ff, 0x0000ffff, 0x000000ff, 0xffffffff].map(x => Colors.toRGBA(x))) { + const hsla = Colors.toHSLA(Colors.fromRGBA(...rgba)); + const rgbaBacktransform = Colors.toRGBA(Colors.fromHSLA(...hsla)); for (let i = 0; i < 4; i++) if (rgba[i] !== rgbaBacktransform[i]) - panic('rgbaToHSLA failed'); + panic('Colors.toHSLA failed'); } } { - const rgb = Image.colorToRGB(0xff8000ff); + const rgb = Colors.toRGB(0xff8000ff); const target = [0xff, 0x80, 0x00]; for (let i = 0; i < 3; i++) if (rgb[i] !== target[i]) diff --git a/tests/palette.js b/tests/palette.js index 8a3f416e..1f4f6fb4 100644 --- a/tests/palette.js +++ b/tests/palette.js @@ -1,4 +1,4 @@ -const {Image, Frame, GainMap} = require('..'); +const {Image, Frame, GainMap, Colors} = require('..'); const panic = message => { console.error(message); @@ -19,7 +19,7 @@ const colorsOf = image => { }; // pseudo-random colors: many distinct ones -const photo = (width, height) => new Image(width, height).fill((x, y) => Image.rgbaToColor((x * 37 + y * 11) % 256, (x * 5 + y * 91) % 256, (x * y) % 256, 255)); +const photo = (width, height) => new Image(width, height).fill((x, y) => Colors.fromRGBA((x * 37 + y * 11) % 256, (x * 5 + y * 91) % 256, (x * y) % 256, 255)); const DITHERS = ['none', 'floyd-steinberg', 'atkinson', 'ordered']; // the mean absolute error of the red channel between 8x8 block averages of two images @@ -69,7 +69,7 @@ const blockError = (original, result) => { } // dithering averages out: 8x8 block averages of a gradient with 4 colors are closer to the original than undithered - const ramp = new Image(128, 16).fill(x => Image.rgbaToColor(Math.round(x * 255 / 127), Math.round(x * 255 / 127), Math.round(x * 255 / 127), 255)); + const ramp = new Image(128, 16).fill(x => Colors.fromRGBA(Math.round(x * 255 / 127), Math.round(x * 255 / 127), Math.round(x * 255 / 127), 255)); const plain = blockError(ramp, ramp.clone().quantize({colors: 4})); for (const dither of ['floyd-steinberg', 'atkinson', 'ordered']) { const error = blockError(ramp, ramp.clone().quantize({colors: 4, dither})); @@ -143,14 +143,14 @@ const blockError = (original, result) => { // frames: quantize their own pixels and return the frame { - const frame = new Frame(20, 10, {duration: 100}).fill((x, y) => Image.rgbaToColor(x * 12, y * 25, 128, 255)); + const frame = new Frame(20, 10, {duration: 100}).fill((x, y) => Colors.fromRGBA(x * 12, y * 25, 128, 255)); if (frame.quantize({colors: 2, dither: 'ordered'}) !== frame) panic('quantize does not return this for a frame'); if (colorsOf(frame).size > 2) panic('frame quantize'); } // an image with a gain map keeps a valid one { - const hdr = new Image(8, 8).fill((x, y) => Image.rgbaToColor(x * 30, y * 30, 200, 255)); + const hdr = new Image(8, 8).fill((x, y) => Colors.fromRGBA(x * 30, y * 30, 200, 255)); hdr.gainMap = new GainMap(new Image(4, 4).fill(0x808080ff), {gainMapMin: 0, gainMapMax: 2}); hdr.quantize({colors: 4, dither: 'floyd-steinberg'}); if (!(hdr.gainMap instanceof GainMap)) panic('gain map removed by quantize'); diff --git a/tests/png.js b/tests/png.js index d2b0949b..1d92eefd 100644 --- a/tests/png.js +++ b/tests/png.js @@ -1,6 +1,6 @@ const fs = require('fs').promises; const crypto = require('crypto'); -const {Image, Animation, Frame} = require('..'); +const {Image, Animation, Frame, Colors} = require('..'); // (for chunks the tests change) const crc32 = data => { @@ -51,7 +51,7 @@ const expected = { // encode/decode roundtrip const image = new Image(97, 61); - image.fill((x, y) => Image.rgbaToColor(x * 2, y * 4, (x * y) & 0xff, (x + y) & 0xff)); + image.fill((x, y) => Colors.fromRGBA(x * 2, y * 4, (x * y) & 0xff, (x + y) & 0xff)); const decoded = await Image.decode(await image.encodePNG(3)); if (!Buffer.from(decoded.bitmap).equals(Buffer.from(image.bitmap))) panic('roundtrip failed'); diff --git a/tests/pnm.js b/tests/pnm.js index 274ad1c2..4ee56e21 100644 --- a/tests/pnm.js +++ b/tests/pnm.js @@ -1,4 +1,4 @@ -const {Image, ImageType} = require('..'); +const {Image, ImageType, Colors} = require('..'); const panic = message => { console.error(message); @@ -54,10 +54,10 @@ const pixels = image => Array.from({length: image.width * image.height}, (_, i) // encoding: PBM for black and white, PGM for greyscale, PPM, PAM with alpha const cases = [ [new Image(13, 5).fill((x, y) => (x + y) & 1 ? 0xffffffff : 0x000000ff), 'P4'], - [new Image(15, 4).fill((x, y) => Image.rgbaToColor(x * 17, x * 17, x * 17, 255)), 'P5'], - [new Image(7, 3).fill((x, y) => Image.rgbaToColor(x * 30, y * 80, 7, 255)), 'P6'], - [new Image(5, 2).fill((x, y) => Image.rgbaToColor(x * 50, x * 50, x * 50, y * 99)), 'P7', 'GRAYSCALE_ALPHA'], - [new Image(5, 2).fill((x, y) => Image.rgbaToColor(x * 50, 3, 9, y * 99)), 'P7', 'RGB_ALPHA'], + [new Image(15, 4).fill((x, y) => Colors.fromRGBA(x * 17, x * 17, x * 17, 255)), 'P5'], + [new Image(7, 3).fill((x, y) => Colors.fromRGBA(x * 30, y * 80, 7, 255)), 'P6'], + [new Image(5, 2).fill((x, y) => Colors.fromRGBA(x * 50, x * 50, x * 50, y * 99)), 'P7', 'GRAYSCALE_ALPHA'], + [new Image(5, 2).fill((x, y) => Colors.fromRGBA(x * 50, 3, 9, y * 99)), 'P7', 'RGB_ALPHA'], ]; for (const [image, magic, type] of cases) { const encoded = await image.encodePNM(); diff --git a/tests/qoi.js b/tests/qoi.js index ef5a7406..a58755de 100644 --- a/tests/qoi.js +++ b/tests/qoi.js @@ -1,5 +1,5 @@ const fs = require('fs').promises; -const {Image, ImageType} = require('..'); +const {Image, ImageType, Colors} = require('..'); const panic = message => { console.error(message); @@ -21,7 +21,7 @@ const panic = message => { // every operation: runs of 62 and longer, the index, small and larger differences, alpha changes const image = new Image(97, 3).fill((x, y) => y === 0 ? 0x336699ff : y === 1 ? [0x336699ff, 0x346799ff, 0x50779aff, 0x336699ff, 0x33669980, 0xffffff00][x % 6] - : Image.rgbaToColor(x * 37 & 255, x * 11 & 255, 255 - x, x & 1 ? 255 : 200)); + : Colors.fromRGBA(x * 37 & 255, x * 11 & 255, 255 - x, x & 1 ? 255 : 200)); const back = await Image.decode(await image.encodeQOI()); if (!Buffer.from(back.bitmap).equals(Buffer.from(image.bitmap))) panic('QOI didn\'t round trip'); const opaque = await new Image(4, 4).fill(0xff0000ff).encodeQOI(); diff --git a/tests/readme.js b/tests/readme.js index b81e4bd2..b0eb45a5 100644 --- a/tests/readme.js +++ b/tests/readme.js @@ -1,5 +1,5 @@ // the image in the README: a weather card of frosted glass over an SVG landscape -const {Image} = require('..'); +const {Image, Colors} = require('..'); const fs = require('fs'); const samePixels = require('./util/pixels'); @@ -57,7 +57,7 @@ const font = name => fs.readFileSync('./tests/fonts/' + name); // the week: bars from the lowest to the highest temperature, in a gradient from cool to warm const days = [['Mon', 14, 21], ['Tue', 13, 19], ['Wed', 15, 23], ['Thu', 16, 25], ['Fri', 14, 22], ['Sat', 12, 18], ['Sun', 13, 20]]; - const gradient = Image.gradient({0: 0x5ac8faff, 0.5: 0xffd166ff, 1: 0xff7a59ff}); + const gradient = Colors.gradient({0: 0x5ac8faff, 0.5: 0xffd166ff, 1: 0xff7a59ff}); const chart = {top: card.y + 300, height: 100, min: 10, max: 26}; const step = (card.width - 80) / days.length; for (const [i, [day, low, high]] of days.entries()) { diff --git a/tests/regressions.js b/tests/regressions.js index 56a83b54..381d09e2 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -1,5 +1,5 @@ const {Image, Frame, GIF, Animation, TextLayout} = require('..'); -const {ResizeModes, SVGScaleModes, FrameDisposalModes, Colors: ColorHelpers} = require('..'); +const {ResizeModes, SVGScaleModes, FrameDisposalModes, Colors} = require('..'); const zlib = require('zlib'); // (encoders stamp the current time into the metadata: tests that compare two encodes freeze it, or they differ when a second ends in between) const withFrozenTime = async run => { @@ -14,12 +14,12 @@ const assert = (condition, message) => { process.exit(1); }; -const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); +const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); (async () => { // rotating non-square images by right angles (#29) { - const image = new Image(3, 2).fill((x, y) => Image.rgbaToColor(x + 1, y + 1, 0, 255)); + const image = new Image(3, 2).fill((x, y) => Colors.fromRGBA(x + 1, y + 1, 0, 255)); const ccw = image.clone().rotate(90); assert(ccw.width === 2 && ccw.height === 3, 'rotate(90): dimensions not swapped'); assert(rgba(ccw, 0, 2).slice(0, 2).join() === '1,1', 'rotate(90): top left should end up bottom left'); @@ -38,10 +38,10 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); // compositing respects the destination alpha { - const image = new Image(1, 1).composite(new Image(1, 1).fill(Image.rgbaToColor(255, 255, 255, 128))); + const image = new Image(1, 1).composite(new Image(1, 1).fill(Colors.fromRGBA(255, 255, 255, 128))); assert(rgba(image, 0, 0).join() === '255,255,255,128', 'composite: semi-transparent white onto transparent turned ' + rgba(image, 0, 0)); - const blend = new Image(1, 1).fill(Image.rgbaToColor(255, 0, 0, 128)).composite(new Image(1, 1).fill(Image.rgbaToColor(0, 0, 255, 128))); + const blend = new Image(1, 1).fill(Colors.fromRGBA(255, 0, 0, 128)).composite(new Image(1, 1).fill(Colors.fromRGBA(0, 0, 255, 128))); assert(rgba(blend, 0, 0)[3] === 192, 'composite: alpha should combine to 0.75'); } @@ -53,7 +53,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); // crop is clipped to the image { - const image = new Image(10, 2).fill((x, y) => Image.rgbaToColor(x + 1, y + 1, 0, 255)).crop(5, 0, 10, 2); + const image = new Image(10, 2).fill((x, y) => Colors.fromRGBA(x + 1, y + 1, 0, 255)).crop(5, 0, 10, 2); assert(image.width === 5 && image.height === 2, 'crop: region not clipped'); assert(rgba(image, 0, 1).slice(0, 2).join() === '6,2', 'crop: wrong pixels'); @@ -71,7 +71,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); // averageColor is weighted by alpha { - assert(new Image(2, 2).fill(Image.rgbaToColor(255, 0, 0, 128)).averageColor() === 0xff0000ff, 'averageColor: semi-transparent red'); + assert(new Image(2, 2).fill(Colors.fromRGBA(255, 0, 0, 128)).averageColor() === 0xff0000ff, 'averageColor: semi-transparent red'); const image = new Image(2, 1); image.setPixelAt(0, 0, 0x00ff00ff); image.setPixelAt(1, 0, 0xff000000); @@ -79,7 +79,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); } // gradients return unsigned colors like rgbaToColor - assert(Image.gradient({0: 0xff0000ff, 1: 0xff0000ff})(0.5) === 0xff0000ff, 'gradient: signed color'); + assert(Colors.gradient({0: 0xff0000ff, 1: 0xff0000ff})(0.5) === 0xff0000ff, 'gradient: signed color'); // PNG metadata as the only argument { @@ -152,7 +152,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); // HSL conversions round instead of truncating, so identity operations keep the image { - const image = new Image(64, 64).fill((x, y) => Image.rgbaToColor(x * 4, y * 4, (x * y) & 255, 255 - x)); + const image = new Image(64, 64).fill((x, y) => Colors.fromRGBA(x * 4, y * 4, (x * y) & 255, 255 - x)); const original = Buffer.from(image.bitmap); assert(Buffer.from(image.clone().lightness(1).bitmap).equals(original), 'lightness(1) should not change the image'); assert(Buffer.from(image.clone().saturation(1).bitmap).equals(original), 'saturation(1) should not change the image'); @@ -161,14 +161,14 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); // negative hues wrap around { - const color = Image.rgbToColor(255, 40, 0); + const color = Colors.fromRGB(255, 40, 0); const shifted = new Image(1, 1).fill(color).hueShift(-300).getPixelAt(0, 0); - assert(shifted === new Image(1, 1).fill(color).hueShift(60).getPixelAt(0, 0), 'hueShift(-300) should equal hueShift(60), got ' + Image.colorToRGBA(shifted)); + assert(shifted === new Image(1, 1).fill(color).hueShift(60).getPixelAt(0, 0), 'hueShift(-300) should equal hueShift(60), got ' + Colors.toRGBA(shifted)); } // compositing rounds to the nearest value instead of up { - const image = new Image(1, 1).fill(Image.rgbaToColor(0, 0, 0, 255)).composite(new Image(1, 1).fill(Image.rgbaToColor(100, 100, 100, 51))); + const image = new Image(1, 1).fill(Colors.fromRGBA(0, 0, 0, 255)).composite(new Image(1, 1).fill(Colors.fromRGBA(100, 100, 100, 51))); assert(rgba(image, 0, 0)[0] === 20, 'composite: 100 at 20% over black should be 20, got ' + rgba(image, 0, 0)[0]); } @@ -251,7 +251,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); const half = new Image(4, 1).fill((x) => x < 2 ? 0xff000000 : 0x0000ffff).resize(2, 1); assert(rgba(half, 0, 0).slice(0, 3).join() === '0,0,255', 'resize: transparent pixels bled into the result: ' + rgba(half, 0, 0)); - const nearest = new Image(4, 1).fill((x) => Image.rgbToColor(x, 0, 0)).resize(2, 1, ResizeModes.NEAREST_NEIGHBOR); + const nearest = new Image(4, 1).fill((x) => Colors.fromRGB(x, 0, 0)).resize(2, 1, ResizeModes.NEAREST_NEIGHBOR); assert(rgba(nearest, 0, 0)[0] === 1 && rgba(nearest, 1, 0)[0] === 3, 'nearest neighbor should sample pixel centers'); // (the upper case names of the removed Image.RESIZE_* constants are unknown modes too) @@ -297,10 +297,10 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); { const cases = { palette: [new Image(20, 10).fill((x) => x & 1 ? 0xff000080 : 0x00ff00ff), 3], - greys: [new Image(300, 1).fill((x) => Image.rgbToColor(x & 255, x & 255, x & 255)), 3], - greyAlpha: [new Image(300, 3).fill((x, y) => Image.rgbaToColor(x & 255, x & 255, x & 255, 100 + y)), 4], - rgb: [new Image(300, 2).fill((x, y) => Image.rgbToColor(x & 255, x >> 8, y)), 2], - rgba: [new Image(300, 2).fill((x, y) => Image.rgbaToColor(x & 255, x >> 8, y, 200)), 6] + greys: [new Image(300, 1).fill((x) => Colors.fromRGB(x & 255, x & 255, x & 255)), 3], + greyAlpha: [new Image(300, 3).fill((x, y) => Colors.fromRGBA(x & 255, x & 255, x & 255, 100 + y)), 4], + rgb: [new Image(300, 2).fill((x, y) => Colors.fromRGB(x & 255, x >> 8, y)), 2], + rgba: [new Image(300, 2).fill((x, y) => Colors.fromRGBA(x & 255, x >> 8, y, 200)), 6] }; for (const [name, [image, type]] of Object.entries(cases)) { const encoded = await image.encodePNG(); @@ -310,7 +310,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); } // truecolor rows are filtered, which makes smooth gradients tiny - const gradient = new Image(512, 512).fill((x, y) => Image.rgbToColor(x / 2, y / 2, (x + y) / 4)); + const gradient = new Image(512, 512).fill((x, y) => Colors.fromRGB(x / 2, y / 2, (x + y) / 4)); const size = (await gradient.encodePNG()).length; assert(size < 20000, `PNG: gradient should compress well with row filters, got ${size} bytes`); assert(Buffer.from((await Image.decode(await gradient.encodePNG(9))).bitmap).equals(Buffer.from(gradient.bitmap)), 'PNG: filtered roundtrip failed'); @@ -318,9 +318,9 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); // invertValue inverts HSV value and invertLightness HSL lightness { - const value = new Image(3, 1).fill(x => [Image.rgbaToColor(200, 100, 50, 128), Image.rgbToColor(0, 0, 0), Image.rgbToColor(255, 128, 0)][x]).invertValue(); + const value = new Image(3, 1).fill(x => [Colors.fromRGBA(200, 100, 50, 128), Colors.fromRGB(0, 0, 0), Colors.fromRGB(255, 128, 0)][x]).invertValue(); assert([0, 1, 2].map(x => rgba(value, x, 0).join(' ')).join() === '55 28 14 128,255 255 255 255,0 0 0 255', 'invertValue should invert HSV value, got ' + [0, 1, 2].map(x => rgba(value, x, 0))); - const lightness = new Image(1, 1).fill(Image.rgbToColor(200, 100, 50)).invertLightness(); + const lightness = new Image(1, 1).fill(Colors.fromRGB(200, 100, 50)).invertLightness(); assert(rgba(lightness, 0, 0).join() === '205,105,55,255', 'invertLightness should invert HSL lightness, got ' + rgba(lightness, 0, 0)); } @@ -359,10 +359,10 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); // color components are rounded and clamped instead of wrapping { - assert(Image.rgbaToColor(256, -1, 127.6, 300) === 0xff0080ff, 'rgbaToColor should clamp and round, got ' + Image.rgbaToColor(256, -1, 127.6, 300).toString(16)); + assert(Colors.fromRGBA(256, -1, 127.6, 300) === 0xff0080ff, 'Colors.fromRGBA should clamp and round, got ' + Colors.fromRGBA(256, -1, 127.6, 300).toString(16)); let threw = false; - try { Image.rgbToColor(NaN, 0, 0); } catch (e) { threw = e instanceof RangeError; } - assert(threw, 'rgbToColor(NaN) should throw a RangeError'); + try { Colors.fromRGB(NaN, 0, 0); } catch (e) { threw = e instanceof RangeError; } + assert(threw, 'Colors.fromRGB(NaN) should throw a RangeError'); } // disposal modes read back as the names they are set with @@ -388,7 +388,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); assert(rendered.width === width && rendered.height === height, `renderSVG mode ${mode}: ${rendered.width}x${rendered.height}`); } assert(new Image(8, 4).resize({width: 4, mode: ResizeModes.AREA}).height === 2, 'resize({width}) with a mode keeps the aspect ratio'); - const photo = new Image(9, 7).fill((x, y) => ColorHelpers.fromRGBA(x * 28, y * 36, (x ^ y) * 16, 255 - x * 10)); + const photo = new Image(9, 7).fill((x, y) => Colors.fromRGBA(x * 28, y * 36, (x ^ y) * 16, 255 - x * 10)); let threw = false; try { photo.clone().resize(4, 4, 'Area'); } catch { threw = true; } assert(threw, 'resize modes are case sensitive'); @@ -397,7 +397,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); // resize({width}) / resize({height}) keep the aspect ratio { const {Animation} = require('..'); - const photo = new Image(12, 8).fill((x, y) => ColorHelpers.fromRGBA(x * 20, y * 30, 90, 255)); + const photo = new Image(12, 8).fill((x, y) => Colors.fromRGBA(x * 20, y * 30, 90, 255)); const same = (a, b) => a.width === b.width && a.height === b.height && Buffer.from(a.bitmap).equals(Buffer.from(b.bitmap)); assert(same(photo.clone().resize({width: 6}), photo.clone().resize(6, 4)), 'resize({width})'); assert(photo.clone().resize({width: 6}).height === 4, 'resize({width}) keeps the aspect ratio'); @@ -420,17 +420,17 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); // coordinates() and pixels(), the iterator and iterateWithColors() (deprecated, removed in 4.0) yield the same { - const image = new Image(3, 2).fill((x, y) => ColorHelpers.fromRGBA(x * 50, y * 100, 7, 255)); + const image = new Image(3, 2).fill((x, y) => Colors.fromRGBA(x * 50, y * 100, 7, 255)); const coordinates = [...image.coordinates()], pixels = [...image.pixels()]; assert(coordinates.length === 6 && pixels.length === 6, 'coordinates()/pixels() count'); assert(JSON.stringify(coordinates) === JSON.stringify([...image]), 'coordinates() vs the iterator'); assert(JSON.stringify(pixels) === JSON.stringify([...image.iterateWithColors()]), 'pixels() vs iterateWithColors()'); - assert(coordinates[4].join() === '1,1' && pixels[4][2] === ColorHelpers.fromRGBA(50, 100, 7, 255), 'coordinates() and pixels() are row by row'); + assert(coordinates[4].join() === '1,1' && pixels[4][2] === Colors.fromRGBA(50, 100, 7, 255), 'coordinates() and pixels() are row by row'); } // cropCircle({size}): 'contain' is the default, `max` (deprecated, removed in 4.0) maps to 'cover' { - const wide = new Image(40, 20).fill(ColorHelpers.fromRGBA(200, 100, 50, 255)); + const wide = new Image(40, 20).fill(Colors.fromRGBA(200, 100, 50, 255)); const same = (a, b) => Buffer.from(a.bitmap).equals(Buffer.from(b.bitmap)); assert(same(wide.clone().cropCircle(), wide.clone().cropCircle({size: 'contain'})), 'cropCircle() is contain'); assert(same(wide.clone().cropCircle(false), wide.clone().cropCircle({size: 'contain'})), 'cropCircle(false)'); @@ -488,7 +488,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); const fs = require('fs'); const pixel = (image, x, y) => Array.from(image.bitmap.subarray((y * image.width + x) * 4, (y * image.width + x) * 4 + 4)); const tiff = orientation => Buffer.from([0x4d, 0x4d, 0, 42, 0, 0, 0, 8, 0, 1, 0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, orientation, 0, 0, 0, 0, 0, 0, 0, 0]); - const source = () => [0, 1].map(i => Frame.from(new Image(3, 2).fill((x, y) => ColorHelpers.fromRGBA(x * 100, y * 100, i * 120, 255)), 100)); + const source = () => [0, 1].map(i => Frame.from(new Image(3, 2).fill((x, y) => Colors.fromRGBA(x * 100, y * 100, i * 120, 255)), 100)); // APNG (eXIf) and animated WebP (EXIF): orientation 6 turns the top left into the top right const png = Buffer.from(await new Animation(source(), 2).encodeAPNG({metadata: {software: ''}})); @@ -593,8 +593,8 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); assert(error instanceof type, `${message}: should throw a ${type.name}, got ${error}`); }; const stops = {0: 0xff0000ff, 0.5: 0x00ff00ff, 1: 0x0000ffff}; - const gradient = ColorHelpers.gradient(stops); - const rgba = color => ColorHelpers.toRGBA(color); + const gradient = Colors.gradient(stops); + const rgba = color => Colors.toRGBA(color); // linear gradients follow Colors.gradient along CSS' directions (0 up, 90 right, 180 down, 270 left) for (const [angle, at] of [[90, x => [x, 0, (x + 0.5) / 4]], [270, x => [x, 0, 1 - (x + 0.5) / 4]]]) { @@ -614,7 +614,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); const diagonal = Image.linearGradient(8, 8, {stops: [0x000000ff, 0xffffffff], angle: 135}); assert(px(diagonal, 0, 0)[0] < 20 && px(diagonal, 7, 7)[0] > 235 && px(diagonal, 7, 0).join() === px(diagonal, 0, 7).join(), 'linearGradient at 135deg runs from corner to corner'); // (an array of colors is evenly spaced, one color is a plain fill, alpha is part of the color) - assert(px(Image.linearGradient(3, 1, {stops: [0xff0000ff, 0x00ff00ff, 0x0000ffff], angle: 90}), 1, 0).join() === rgba(ColorHelpers.gradient({0: 0xff0000ff, 0.5: 0x00ff00ff, 1: 0x0000ffff})(0.5)).join(), 'linearGradient with an array of colors'); + assert(px(Image.linearGradient(3, 1, {stops: [0xff0000ff, 0x00ff00ff, 0x0000ffff], angle: 90}), 1, 0).join() === rgba(Colors.gradient({0: 0xff0000ff, 0.5: 0x00ff00ff, 1: 0x0000ffff})(0.5)).join(), 'linearGradient with an array of colors'); assert(px(Image.linearGradient(3, 3, {stops: [0x336699ff]}), 2, 2).join() === '51,102,153,255', 'linearGradient with one color'); assert(px(Image.linearGradient(2, 1, {stops: [0xff000000, 0xff0000ff], angle: 90}), 0, 0)[3] < 100, 'linearGradient keeps alpha'); @@ -706,18 +706,10 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); assert(glowOnText.width > 16 && glowOnText.height > 18, 'outline and dropShadow chain'); } - // Colors: the color helpers, and the deprecated Image ones they replaced, give the same values + // Colors: the color helpers { - const { Colors } = require('..'); - assert(Colors.fromRGBA(10, 20, 30, 40) === Image.rgbaToColor(10, 20, 30, 40), 'Colors.fromRGBA'); - assert(Colors.fromRGB(1, 2, 3) === Image.rgbToColor(1, 2, 3), 'Colors.fromRGB'); - assert(Colors.fromHSLA(0.3, 0.5, 0.6, 0.7) === Image.hslaToColor(0.3, 0.5, 0.6, 0.7), 'Colors.fromHSLA'); - assert(Colors.fromHSL(0.9, 1, 0.25) === Image.hslToColor(0.9, 1, 0.25), 'Colors.fromHSL'); - assert(Colors.toRGBA(0x11223344).join() === Image.colorToRGBA(0x11223344).join(), 'Colors.toRGBA'); assert(Colors.toRGB(0x11223344).join() === '17,34,51', 'Colors.toRGB ignores alpha'); - assert(Colors.toHSLA(0xff0000ff).join() === Image.rgbaToHSLA(255, 0, 0, 255).join() && Colors.toHSLA(0xff0000ff)[0] === 0, 'Colors.toHSLA'); - const stops = {0: 0xff0000ff, 0.5: 0x00ff00ff, 1: 0x0000ffff}; - assert(Colors.gradient(stops)(0.25) === Image.gradient(stops)(0.25) && Colors.gradient(stops)(0.75) === Image.gradient(stops)(0.75), 'Colors.gradient'); + assert(Colors.toHSLA(0xff0000ff)[0] === 0, 'Colors.toHSLA'); // HSLA round trip: each channel comes back within one step const rgba = Colors.toRGBA(Colors.fromRGBA(200, 100, 50, 255)), [h, s2, l, a2] = Colors.toHSLA(Colors.fromRGBA(200, 100, 50, 255)); const back = Colors.toRGBA(Colors.fromHSLA(h, s2, l, a2)); @@ -767,14 +759,14 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); const edge = new Image(16, 1).fill((x) => x < 8 ? 0x000000ff : 0xffffffff); const sharpened = edge.clone().sharpen(1, 1); assert(rgba(sharpened, 7, 0)[0] === 0 && rgba(sharpened, 8, 0)[0] === 255, 'sharpen should keep a hard edge hard'); - const soft = new Image(16, 1).fill((x) => Image.rgbToColor(x * 16, x * 16, x * 16)).blur(1); + const soft = new Image(16, 1).fill((x) => Colors.fromRGB(x * 16, x * 16, x * 16)).blur(1); const sharp = soft.clone().sharpen(3, 2); assert(rgba(sharp, 15, 0)[0] >= rgba(soft, 15, 0)[0] && rgba(sharp, 0, 0)[0] <= rgba(soft, 0, 0)[0], 'sharpen should increase contrast'); } // rgb adjustments { - const image = new Image(64, 4).fill((x, y) => Image.rgbaToColor(x * 4, 255 - x * 4, y * 60, 200)); + const image = new Image(64, 4).fill((x, y) => Colors.fromRGBA(x * 4, 255 - x * 4, y * 60, 200)); for (const [name, op] of [['brightness(1)', i => i.brightness(1)], ['contrast(1)', i => i.contrast(1)], ['gamma(1)', i => i.gamma(1)]]) assert(Buffer.from(op(image.clone()).bitmap).equals(Buffer.from(image.bitmap)), `${name} should not change the image`); @@ -807,7 +799,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); // also for translucent pixels, off the edges let seed = 3; const random = () => (seed = Math.imul(seed, 1103515245) + 12345 >>> 0) / 2 ** 32; - const pixel = () => Image.rgbaToColor(random() * 256 | 0, random() * 256 | 0, random() * 256 | 0, [0, 255, random() * 256 | 0][random() * 3 | 0]); + const pixel = () => Colors.fromRGBA(random() * 256 | 0, random() * 256 | 0, random() * 256 | 0, [0, 255, random() * 256 | 0][random() * 3 | 0]); const backdrop = new Image(24, 16).fill(pixel), source = new Image(16, 24).fill(pixel); for (const opacity of [0.3, 0.85, 0.999]) { const expected = backdrop.clone().__blend__(source, 12, -4, opacity, (_, s) => s).bitmap; @@ -868,7 +860,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); xmp: o => chunk('iTXt', Buffer.concat([Buffer.from('XML:com.adobe.xmp\0\x01\0\0\0', 'latin1'), zlib.deflateSync(xmp(o))])), }; - const source = new Image(3, 2).fill((x, y) => Image.rgbaToColor(x * 100, y * 100, 0, 255)); + const source = new Image(3, 2).fill((x, y) => Colors.fromRGBA(x * 100, y * 100, 0, 255)); const encoded = Buffer.from(await source.encodePNG()); const withChunks = (...extra) => Buffer.concat([encoded.subarray(0, encoded.length - 12), ...extra, encoded.subarray(encoded.length - 12)]); @@ -918,7 +910,7 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); // noisy rows on top and a noisy strip in the bottom left, the rest transparent const image = new Image(width, height).fill((x, y) => { const c = y < rows || y > height * 2 / 3 && x < 64 ? random() * colors | 0 : 0; - return c ? Image.rgbaToColor(c * 37, c * 11, c, 255) : 0; + return c ? Colors.fromRGBA(c * 37, c * 11, c, 255) : 0; }); for (const options of [{}, {quality: 85}]) { const decoded = await Image.decode(await image.encodeWebP(options)).catch(err => assert(false, `WebP ${JSON.stringify(options)} of the ${width}x${height} mask: ${err.message}`)); @@ -1055,12 +1047,12 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y)); const {Frame, Animation, TextLayout} = require('..'); const image = () => new Image(8, 8).fill(0xff0000ff); const calls = { - rgbaToColor: v => Image.rgbaToColor(v, 0, 0, 255), hslaToColor: v => Image.hslaToColor(v, 0.5, 0.5, 1), colorToRGBA: v => Image.colorToRGBA(v), + rgbaToColor: v => Colors.fromRGBA(v, 0, 0, 255), hslaToColor: v => Colors.fromHSLA(v, 0.5, 0.5, 1), colorToRGBA: v => Colors.toRGBA(v), getPixelAt: v => image().getPixelAt(v, 1), setPixelAt: v => image().setPixelAt(1, 1, v), fill: v => image().fill(v), contain: v => image().contain(v, 4), crop: v => image().crop(v, 0, 2, 2), trim: v => image().trim(v), drawBox: v => image().drawBox(0, 0, v, 2, 0), drawCircle: v => image().drawCircle(2, 2, v, 0), cropCircle: v => image().cropCircle(false, v), opacity: v => image().opacity(v), red: v => image().red(v), lightness: v => image().lightness(v), composite: v => image().composite(image(), v, 0), hueShift: v => image().hueShift(v), brightness: v => image().brightness(v), gamma: v => image().gamma(v), - colorToAlpha: v => image().colorToAlpha(v), rotate: v => image().rotate(v, false), gradient: v => Image.gradient({0: v, 1: 0}), + colorToAlpha: v => image().colorToAlpha(v), rotate: v => image().rotate(v, false), gradient: v => Colors.gradient({0: v, 1: 0}), roundCorners: v => image().roundCorners(v), fisheye: v => image().fisheye(v), encodePNG: v => image().encodePNG(v), encodeJPEG: v => image().encodeJPEG(v), frame: v => new Frame(4, 4, v), animation: v => new Animation([new Frame(4, 4)], v), }; diff --git a/tests/rotate.js b/tests/rotate.js index 76c592f0..ee0a6762 100644 --- a/tests/rotate.js +++ b/tests/rotate.js @@ -1,5 +1,5 @@ const fs = require('fs').promises; -const {Image} = require('..'); +const {Image, Colors} = require('..'); const samePixels = require('./util/pixels'); const panic = msg => { console.error(msg); @@ -75,7 +75,7 @@ const panic = msg => { { const image = new Image(512, 512); - image.fill((x) => Image.hslToColor(x / image.width, 1, .5)); + image.fill((x) => Colors.fromHSL(x / image.width, 1, .5)); if (!Buffer.from(image.bitmap).equals(Buffer.from(image.rotate(360).bitmap))) panic('rotate 360 failed'); } diff --git a/tests/svg-images.js b/tests/svg-images.js index e17b54be..dfe4f4ce 100644 --- a/tests/svg-images.js +++ b/tests/svg-images.js @@ -1,4 +1,4 @@ -const {Image, Animation, Frame} = require('..'); +const {Image, Animation, Frame, Colors} = require('..'); const panic = message => { console.error(message); @@ -44,9 +44,9 @@ const crc32 = bytes => { }; (async () => { - const logo = new Image(4, 3).fill((x, y) => Image.rgbaToColor(x * 60, y * 80, 128, x === 0 ? 0 : 255)); - const other = new Image(4, 3).fill((x, y) => Image.rgbaToColor(255 - x * 60, y * 40, 200, 255)); - const opaque = new Image(4, 3).fill((x, y) => Image.rgbaToColor(x * 60, y * 80, 128, 255)); + const logo = new Image(4, 3).fill((x, y) => Colors.fromRGBA(x * 60, y * 80, 128, x === 0 ? 0 : 255)); + const other = new Image(4, 3).fill((x, y) => Colors.fromRGBA(255 - x * 60, y * 40, 200, 255)); + const opaque = new Image(4, 3).fill((x, y) => Colors.fromRGBA(x * 60, y * 80, 128, 255)); const png = await logo.encodePNG(1); const webp = await logo.encodeWebP(); const jpeg = await opaque.encodeJPEG(90); @@ -165,7 +165,7 @@ const crc32 = bytes => { // quantized images can be given too (the palette of an SVG's image) { - const reduced = await new Image(4, 3).fill((x, y) => Image.rgbaToColor(x * 60, y * 80, 128, 255)).quantize({colors: 4}).encodePNG(1); + const reduced = await new Image(4, 3).fill((x, y) => Colors.fromRGBA(x * 60, y * 80, 128, 255)).quantize({colors: 4}).encodePNG(1); const result = await Image.renderSVG(svg(''), {images: {'logo.png': reduced}}); if (transparent(result)) panic('a reduced image was not drawn'); } diff --git a/tests/svg.js b/tests/svg.js index 7f876968..cf5dd2e2 100644 --- a/tests/svg.js +++ b/tests/svg.js @@ -1,4 +1,4 @@ -const {Image, SVGScaleModes} = require('..'); +const {Image, SVGScaleModes, Colors} = require('..'); const fs = require('fs').promises; const samePixels = require('./util/pixels'); @@ -42,7 +42,7 @@ const panic = message => { { // images embedded as data URIs: placed at their size, the pixels are the decoded image's const {Animation, Frame} = require('..'); - const pixel = (x, y, alpha) => Image.rgbaToColor(x * 10, y * 15, 128, alpha && x > 20 ? 0 : 255); + const pixel = (x, y, alpha) => Colors.fromRGBA(x * 10, y * 15, 128, alpha && x > 20 ? 0 : 255); const source = new Image(24, 16), opaque = new Image(24, 16); for (let y = 0; y < 16; y++) for (let x = 0; x < 24; x++) { diff --git a/tests/tga.js b/tests/tga.js index 2db631c6..46d4439a 100644 --- a/tests/tga.js +++ b/tests/tga.js @@ -1,4 +1,4 @@ -const {Image, ImageType} = require('..'); +const {Image, ImageType, Colors} = require('..'); const panic = message => { console.error(message); @@ -53,7 +53,7 @@ const rgba = (image, x, y) => image.getRGBAAt(x, y).join(); // encoding round trips, opaque images are stored with 24 bits for (const image of [ - new Image(50, 7).fill((x, y) => Image.rgbaToColor(x * 5, y * 30, (x >> 2) * 40, x % 3 ? 255 : 100)), + new Image(50, 7).fill((x, y) => Colors.fromRGBA(x * 5, y * 30, (x >> 2) * 40, x % 3 ? 255 : 100)), new Image(300, 2).fill(0x336699ff), new Image(1, 1).fill(0x00000000), // a header per pixel: one pixel rows, alternating diff --git a/tests/transform.js b/tests/transform.js index c0259447..f9f0b08f 100644 --- a/tests/transform.js +++ b/tests/transform.js @@ -1,4 +1,4 @@ -const {Image, Frame, GainMap} = require('..'); +const {Image, Frame, GainMap, Colors} = require('..'); const panic = message => { console.error(message); @@ -15,7 +15,7 @@ const throwsA = (fn, type, label) => { const px = (image, x, y) => Array.from(image.bitmap.subarray((y * image.width + x) * 4, (y * image.width + x) * 4 + 4)); const same = (a, b) => a.width === b.width && a.height === b.height && Buffer.from(a.bitmap).equals(Buffer.from(b.bitmap)); // an opaque image where every pixel is different -const photo = (width, height) => new Image(width, height).fill((x, y) => Image.rgbaToColor((x * 37 + y * 11) % 256, (x * 5 + y * 91) % 256, (x * y + x) % 256, 255)); +const photo = (width, height) => new Image(width, height).fill((x, y) => Colors.fromRGBA((x * 37 + y * 11) % 256, (x * 5 + y * 91) % 256, (x * y + x) % 256, 255)); (async () => { // transform diff --git a/tests/webp-lossy.js b/tests/webp-lossy.js index 65931409..8c722066 100644 --- a/tests/webp-lossy.js +++ b/tests/webp-lossy.js @@ -1,6 +1,6 @@ const fs = require('fs').promises; const crypto = require('crypto'); -const {Image, Animation, Frame} = require('..'); +const {Image, Animation, Frame, Colors} = require('..'); const panic = message => { console.error(message); @@ -120,7 +120,7 @@ const alphaReferences = { // noisy alpha: stored uncompressed (smaller than compressed), still lossless let seed = 1; const random = () => (seed = seed * 1103515245 + 12345 >>> 0) >>> 24; - const noisy = new Image(24, 24).fill((x, y) => Image.rgbaToColor(x * 10, y * 10, 99, random())); + const noisy = new Image(24, 24).fill((x, y) => Colors.fromRGBA(x * 10, y * 10, 99, random())); const noisyWebp = await noisy.encodeWebP({quality: 50}); if (chunks(noisyWebp).ALPH[0] !== 0) panic('noisy alpha should be stored uncompressed and unfiltered'); const noisyBack = await Image.decode(noisyWebp); @@ -150,14 +150,14 @@ const alphaReferences = { // (the last frame is half transparent: it's blended over the one before) const lossless = await Animation.decode(await animation.encodeWebP()); for (let f = 0; f < 3; f++) { - const [r, g, b, a] = Image.colorToRGBA(decoded[f].getPixelAt(1, 1)); - const [er, eg, eb, ea] = Image.colorToRGBA(lossless[f].getPixelAt(1, 1)); + const [r, g, b, a] = Colors.toRGBA(decoded[f].getPixelAt(1, 1)); + const [er, eg, eb, ea] = Colors.toRGBA(lossless[f].getPixelAt(1, 1)); if (Math.abs(r - er) + Math.abs(g - eg) + Math.abs(b - eb) > 12 || a !== ea) panic(`lossy animation: frame ${f} decodes to ${[r, g, b, a]}`); } if (!await animation.encodeWebP({quality: 200}).then(() => false, e => e instanceof RangeError)) panic('animation quality 200 should throw'); // alphaQuality for animations, also encoded a frame at a time - const faded = new Animation([0, 1].map(i => Frame.from(new Image(40, 30).fill((x, y) => Image.rgbaToColor(200, 50 * i, 9, x * 6 + y)), 100)), 0); + const faded = new Animation([0, 1].map(i => Frame.from(new Image(40, 30).fill((x, y) => Colors.fromRGBA(200, 50 * i, 9, x * 6 + y)), 100)), 0); // (the same creation time: it's written in seconds, two encodes can be a second apart) const metadata = {creationTime: 0}; const fadedWebp = await faded.encodeWebP({quality: 80, alphaQuality: 0, optimize: false, metadata}); diff --git a/tests/webp.js b/tests/webp.js index 9c9fa3c9..2396e80d 100644 --- a/tests/webp.js +++ b/tests/webp.js @@ -1,6 +1,6 @@ const fs = require('fs').promises; const crypto = require('crypto'); -const {Image, Animation, ImageType, decode} = require('..'); +const {Image, Animation, ImageType, decode, Colors} = require('..'); const panic = message => { console.error(message); @@ -77,7 +77,7 @@ const animated = [ const cases = [ photo, new Image(37, 23).fill((x, y) => (x ^ y) & 4 ? 0xff0000ff : 0x00000000), - new Image(64, 9).fill((x, y) => Image.rgbaToColor(x * 4, y * 28, 7, (x * 9) & 255)), + new Image(64, 9).fill((x, y) => Colors.fromRGBA(x * 4, y * 28, 7, (x * 9) & 255)), new Image(1, 1).fill(0x12345678) ]; // (every effort: 0 the fast mode, 1 without comparing the reference searches, 2 and up all of it) @@ -95,7 +95,7 @@ const animated = [ for (const effort of [-1, 7, 1.5]) if (!await photo.encodeWebP({effort}).then(() => false, e => e instanceof RangeError)) panic(`effort ${effort} should throw`); // threads (the predictors and prefix groups searched in parallel, from 1.5 megapixels on): the same file as without { - const big = new Image(1448, 1448).fill((x, y) => Image.rgbaToColor(x * 7 + y & 255, (x * x + y * 3) >> 4 & 255, (x ^ y) & 255, 255)); + const big = new Image(1448, 1448).fill((x, y) => Colors.fromRGBA(x * 7 + y & 255, (x * x + y * 3) >> 4 & 255, (x ^ y) & 255, 255)); const metadata = {creationTime: 0}; for (const effort of [0, 1, 2]) { const one = await big.encodeWebP({threads: 1, metadata, effort}), four = await big.encodeWebP({threads: 4, metadata, effort}); From 183739cc73f373ed829afe05da6ba0252feac6ac Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 16:25:55 +0000 Subject: [PATCH 005/188] remove Image[Symbol.iterator] and iterateWithColors --- CHANGELOG.md | 2 ++ src/image.ts | 20 -------------------- tests/misc.js | 8 ++++---- tests/regressions.js | 20 ++++++++++---------- 4 files changed, 16 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75413dc3..a7e19779 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ - **The color helpers of `Image` are removed**: `Image.rgbaToColor()`, `Image.rgbToColor()`, `Image.hslaToColor()`, `Image.hslToColor()`, `Image.rgbaToHSLA()`, `Image.colorToRGBA()`, `Image.colorToRGB()` and `Image.gradient()` are now `Colors.fromRGBA()`, `Colors.fromRGB()`, `Colors.fromHSLA()`, `Colors.fromHSL()`, `Colors.toHSLA()`, `Colors.toRGBA()`, `Colors.toRGB()` and `Colors.gradient()`. +- **Iterating over an image and `iterateWithColors()` are removed**: `for (const [x, y] of image)` is now `image.coordinates()`, and `image.iterateWithColors()` is `image.pixels()`, which yields `[x, y, color]`. + ## 3.20.1 ### Performance diff --git a/src/image.ts b/src/image.ts index a372f168..0a8ece56 100644 --- a/src/image.ts +++ b/src/image.ts @@ -774,26 +774,6 @@ export class Image { yield [x, y, view.getUint32(offset, false)]; } - /** - * Yields an [x, y] array for every pixel in the image (0-based) - * @yields The coordinates of the pixel ([x, y]) - * @deprecated Use {@link Image.coordinates}, iterating over the image will be removed in 4.0 - * @group Deprecated - */ - *[Symbol.iterator](): Generator<[x: number, y: number], void, undefined> { - yield* this.coordinates(); - } - - /** - * Yields an [x, y, color] array for every pixel in the image (0-based) - * @yields The coordinates and color of the pixel ([x, y, color]) - * @deprecated Use {@link Image.pixels}, `iterateWithColors()` will be removed in 4.0 - * @group Deprecated - */ - *iterateWithColors(): Generator<[x: number, y: number, color: Color], void, undefined> { - yield* this.pixels(); - } - /** * Creates an image with a linear gradient, like CSS' `linear-gradient()` * @param width The image's width diff --git a/tests/misc.js b/tests/misc.js index f475f8af..3849a2c9 100644 --- a/tests/misc.js +++ b/tests/misc.js @@ -19,11 +19,11 @@ try { if (new Image(1, 1).toString() !== 'Image<1x1>') panic('toString failed'); -for (const [x, y] of new Image(1, 1)) - if (x !== 0 || y !== 0) panic('Symbol.iterator failed'); +for (const [x, y] of new Image(1, 1).coordinates()) + if (x !== 0 || y !== 0) panic('coordinates failed'); -for (const [x, y, color] of new Image(1, 1).fill(0xff8000ff).iterateWithColors()) - if (x !== 0 || y !== 0 || color !== 0xff8000ff) panic('iterateWithColors failed'); +for (const [x, y, color] of new Image(1, 1).fill(0xff8000ff).pixels()) + if (x !== 0 || y !== 0 || color !== 0xff8000ff) panic('pixels failed'); if (Colors.fromRGB(0xff, 0x80, 0x00) !== 0xff8000ff) panic('rgbToColor failed'); diff --git a/tests/regressions.js b/tests/regressions.js index 381d09e2..c4f11caf 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -27,13 +27,13 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const cw = image.clone().rotate(270); assert(rgba(cw, 1, 0).slice(0, 2).join() === '1,1', 'rotate(270): top left should end up top right'); - for (const [x, y, color] of cw.iterateWithColors()) assert((color & 0xff) === 255, `rotate(270): hole at ${x},${y}`); + for (const [x, y, color] of cw.pixels()) assert((color & 0xff) === 255, `rotate(270): hole at ${x},${y}`); } // free rotation keeps the edges { const image = new Image(4, 4).fill(0xff0000ff).rotate(0.001, false); - for (const [x, y, color] of image.iterateWithColors()) assert((color & 0xff) > 250, `rotate: edge pixel ${x},${y} lost`); + for (const [x, y, color] of image.pixels()) assert((color & 0xff) > 250, `rotate: edge pixel ${x},${y} lost`); } // compositing respects the destination alpha @@ -47,7 +47,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // cropCircle(max) { - const count = image => [...image.iterateWithColors()].filter(([, , c]) => c & 0xff).length; + const count = image => [...image.pixels()].filter(([, , c]) => c & 0xff).length; assert(count(new Image(8, 2).fill(0xffffffff).cropCircle(true)) > count(new Image(8, 2).fill(0xffffffff).cropCircle(false)), 'cropCircle: max has no effect'); } @@ -118,7 +118,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const image = new Image(3, 3); image.setPixelAt(0, 0, 0xff0000ff); assert(image.bitmap[0] === 0xff, 'setPixelAt(0, 0) should set the first pixel'); - assert([...image][0].join() === '0,0' && [...image].pop().join() === '2,2', 'iterator should be 0-based'); + assert([...image.coordinates()][0].join() === '0,0' && [...image.coordinates()].pop().join() === '2,2', 'coordinates() should be 0-based'); let threw = false; try { image.getPixelAt(3, 0); } catch { threw = true; } @@ -243,7 +243,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const checker = new Image(64, 64).fill((x, y) => (x + y) & 1 ? 0xffffffff : 0x000000ff); for (const mode of [ResizeModes.BILINEAR, ResizeModes.BICUBIC, ResizeModes.AREA]) { const small = checker.clone().resize(8, 8, mode); - for (const [x, y, color] of small.iterateWithColors()) + for (const [x, y, color] of small.pixels()) assert(Math.abs((color >>> 24) - 128) <= 2, `${mode}: checkerboard should average to grey at ${x},${y}, got ${color >>> 24}`); } @@ -418,13 +418,13 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); assert(animation.width === 6 && animation.height === 4 && [...animation].every((f, i) => same(f, other[i])), 'Animation.resize({height})'); } - // coordinates() and pixels(), the iterator and iterateWithColors() (deprecated, removed in 4.0) yield the same + // coordinates() and pixels() yield every pixel row by row { const image = new Image(3, 2).fill((x, y) => Colors.fromRGBA(x * 50, y * 100, 7, 255)); const coordinates = [...image.coordinates()], pixels = [...image.pixels()]; assert(coordinates.length === 6 && pixels.length === 6, 'coordinates()/pixels() count'); - assert(JSON.stringify(coordinates) === JSON.stringify([...image]), 'coordinates() vs the iterator'); - assert(JSON.stringify(pixels) === JSON.stringify([...image.iterateWithColors()]), 'pixels() vs iterateWithColors()'); + assert(coordinates.every(([x, y], i) => x === i % 3 && y === Math.floor(i / 3)), 'coordinates() row by row'); + assert(pixels.every(([x, y, color], i) => x === i % 3 && y === Math.floor(i / 3) && color === Colors.fromRGBA(x * 50, y * 100, 7, 255)), 'pixels() row by row'); assert(coordinates[4].join() === '1,1' && pixels[4][2] === Colors.fromRGBA(50, 100, 7, 255), 'coordinates() and pixels() are row by row'); } @@ -820,7 +820,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const outlined = await Image.renderText(font, 40, 'Hi', 0xffffffff, undefined, {strokeWidth: 3, strokeColor: 0xff0000ff}); assert(outlined.width === plain.width + 6 && outlined.height === plain.height + 6, 'stroke should grow the image'); let red = 0, white = 0; - for (const [, , color] of outlined.iterateWithColors()) { + for (const [, , color] of outlined.pixels()) { if (color === 0xff0000ff) red++; if (color === 0xffffffff) white++; } @@ -831,7 +831,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const fill = await Image.renderText(font, 40, 'Hi', 0x0000ff80); let inside = 0, tinted = 0; // pixels the glyphs cover fully (the stroked image is 3 pixels larger on every side) - for (const [x, y, color] of fill.iterateWithColors()) { + for (const [x, y, color] of fill.pixels()) { if (color !== 0x0000ff80) continue; inside++; if (translucent.getPixelAt(x + 3, y + 3) !== 0x0000ff80) tinted++; From 13afda1e387179c182121711c24edbcc495e2513 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 16:26:35 +0000 Subject: [PATCH 006/188] remove PNGMetadata --- CHANGELOG.md | 2 ++ src/image.ts | 7 ------- src/index.ts | 2 +- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7e19779..fefdd018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ - **Iterating over an image and `iterateWithColors()` are removed**: `for (const [x, y] of image)` is now `image.coordinates()`, and `image.iterateWithColors()` is `image.pixels()`, which yields `[x, y, color]`. +- **`PNGMetadata` is removed**: use `Metadata`, which all encoders take. The type is the same. + ## 3.20.1 ### Performance diff --git a/src/image.ts b/src/image.ts index 0a8ece56..57b34b27 100644 --- a/src/image.ts +++ b/src/image.ts @@ -609,13 +609,6 @@ export interface ColorToAlphaOptions { opacityThreshold?: number; } -/** - * Text metadata written into PNGs - * @deprecated Use {@link Metadata}, which all encoders take - * @group Deprecated - */ -export type PNGMetadata = Metadata; - // OpenType features as tags and values (tags are 4 printable ASCII characters) function featureList(features: Record): Array<[string, number]> { if (typeof features !== 'object' || features === null) throw new TypeError('Features must be an object'); diff --git a/src/index.ts b/src/index.ts index afa817aa..3984858a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,7 @@ import { ImageType } from './image-type.ts'; export { Image, ResizeModes, SVGScaleModes } from './image.ts'; export { Colors } from './colors.ts'; -export type { Color, ColorFunction, ResizeMode, SVGScaleMode, PNGMetadata, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, SVGOptions, TextOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts'; +export type { Color, ColorFunction, ResizeMode, SVGScaleMode, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, SVGOptions, TextOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts'; export { Frame, FrameDisposalModes } from './frame.ts'; export type { FrameDisposalModeName, FrameOptions } from './frame.ts'; export { GainMap } from './gainmap.ts'; From 145a62c3ef38cc75be6ae3ea9f390ac64bd9a96e Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 16:27:50 +0000 Subject: [PATCH 007/188] remove the max argument of cropCircle --- CHANGELOG.md | 2 ++ src/image.ts | 19 ++----------------- tests/circle.js | 2 +- tests/regressions.js | 16 +++++----------- 4 files changed, 10 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fefdd018..d3a462cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ - **`PNGMetadata` is removed**: use `Metadata`, which all encoders take. The type is the same. +- **The `max` argument of `cropCircle()` is removed**: `cropCircle(true, feathering)` and `cropCircle({ max: true })` are now `cropCircle({ size: 'cover', feathering })`, and `cropCircle(false)` is `cropCircle()`, the default `size: 'contain'`. + ## 3.20.1 ### Performance diff --git a/src/image.ts b/src/image.ts index 57b34b27..a1dddc07 100644 --- a/src/image.ts +++ b/src/image.ts @@ -553,11 +553,6 @@ export interface CropCircleOptions { * transparent on the shorter side). Default: `'contain'` */ size?: 'contain' | 'cover'; - /** - * Whether to use the larger dimension for the size. Default: `false` - * @deprecated Use `size: 'cover'` (`true`) or `size: 'contain'` (`false`), `max` will be removed in 4.0 - */ - max?: boolean; /** Share of the radius (0..1) over which the edge fades out. Default: `0` */ feathering?: number; } @@ -1402,18 +1397,8 @@ export class Image { * Crops the image into a circle with an antialiased edge * @param options See {@link CropCircleOptions} */ - cropCircle(options?: CropCircleOptions): this; - /** - * Crops the image into a circle with an antialiased edge - * @param max Whether to use the larger dimension for the size. Default: `false` - * @param feathering Share of the radius (0..1) over which the edge fades out. Default: `0` - * @deprecated Pass {@link CropCircleOptions} instead, `cropCircle({ size: 'cover' })`. The arguments will be - * removed in 4.0 - */ - cropCircle(max?: boolean, feathering?: number): this; - cropCircle(maxOrOptions: boolean | CropCircleOptions = false, featheringArgument = 0): this { - const { max = false, size = max ? 'cover' : 'contain', feathering = 0 } = typeof maxOrOptions === 'object' && maxOrOptions !== null - ? maxOrOptions : { max: maxOrOptions, feathering: featheringArgument }; + cropCircle(options: CropCircleOptions = {}): this { + const { size = 'contain', feathering = 0 } = options; finite(feathering, 'feathering'); if (size !== 'contain' && size !== 'cover') throw new TypeError(`Invalid circle size: ${String(size)}`); const radius = (size === 'cover' ? Math.max : Math.min)(this.width, this.height) / 2; diff --git a/tests/circle.js b/tests/circle.js index 87042f6d..05c05bdb 100644 --- a/tests/circle.js +++ b/tests/circle.js @@ -51,7 +51,7 @@ const panic = message => { { const image = new Image(256, 512); image.fill(0x000000ff); - image.cropCircle(true, .5); + image.cropCircle({ size: 'cover', feathering: .5 }); const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); diff --git a/tests/regressions.js b/tests/regressions.js index c4f11caf..bf98dbc3 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -45,10 +45,10 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); assert(rgba(blend, 0, 0)[3] === 192, 'composite: alpha should combine to 0.75'); } - // cropCircle(max) + // cropCircle({ size }) { const count = image => [...image.pixels()].filter(([, , c]) => c & 0xff).length; - assert(count(new Image(8, 2).fill(0xffffffff).cropCircle(true)) > count(new Image(8, 2).fill(0xffffffff).cropCircle(false)), 'cropCircle: max has no effect'); + assert(count(new Image(8, 2).fill(0xffffffff).cropCircle({ size: 'cover' })) > count(new Image(8, 2).fill(0xffffffff).cropCircle({ size: 'contain' })), 'cropCircle: cover has no effect'); } // crop is clipped to the image @@ -277,7 +277,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); for (let x = 0; x < 6; x++) assert(a(x, y) === a(5 - x, y) && a(x, y) === a(x, 5 - y), `cropCircle should be symmetric (${x}, ${y})`); - const feathered = new Image(41, 41).fill(0xffffffff).cropCircle(false, 0.5); + const feathered = new Image(41, 41).fill(0xffffffff).cropCircle({ feathering: 0.5 }); const f = x => feathered.getPixelAt(x, 20) & 0xff; assert(f(20) === 255 && f(12) === 255 && f(5) > 0 && f(5) < 255 && f(0) < 32, 'cropCircle feathering should only soften the outer part'); } @@ -428,16 +428,11 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); assert(coordinates[4].join() === '1,1' && pixels[4][2] === Colors.fromRGBA(50, 100, 7, 255), 'coordinates() and pixels() are row by row'); } - // cropCircle({size}): 'contain' is the default, `max` (deprecated, removed in 4.0) maps to 'cover' + // cropCircle({size}): 'contain' is the default { const wide = new Image(40, 20).fill(Colors.fromRGBA(200, 100, 50, 255)); const same = (a, b) => Buffer.from(a.bitmap).equals(Buffer.from(b.bitmap)); assert(same(wide.clone().cropCircle(), wide.clone().cropCircle({size: 'contain'})), 'cropCircle() is contain'); - assert(same(wide.clone().cropCircle(false), wide.clone().cropCircle({size: 'contain'})), 'cropCircle(false)'); - assert(same(wide.clone().cropCircle({max: false}), wide.clone().cropCircle({size: 'contain'})), 'cropCircle({max: false})'); - assert(same(wide.clone().cropCircle(true), wide.clone().cropCircle({size: 'cover'})), 'cropCircle(true)'); - assert(same(wide.clone().cropCircle({max: true, feathering: .5}), wide.clone().cropCircle({size: 'cover', feathering: .5})), 'cropCircle({max: true, feathering})'); - assert(same(wide.clone().cropCircle({max: true, size: 'contain'}), wide.clone().cropCircle({size: 'contain'})), 'size wins over max'); assert(!same(wide.clone().cropCircle({size: 'cover'}), wide.clone().cropCircle({size: 'contain'})), 'cover differs from contain'); assert(wide.clone().cropCircle({size: 'contain'}).getPixelAt(20, 1) >>> 0 !== 0 && (wide.clone().cropCircle({size: 'contain'}).getPixelAt(1, 10) & 0xff) === 0, 'contain: the circle fits the height'); let threw = false; @@ -730,7 +725,6 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // options objects as alternatives to positional arguments { const base = new Image(20, 20).fill(0xff0000ff); - assert(Buffer.from(base.clone().cropCircle({max: true, feathering: 0.3}).bitmap).equals(Buffer.from(base.clone().cropCircle(true, 0.3).bitmap)), 'cropCircle options'); assert(base.clone().rotate(45, {resize: false}).width === 20 && base.clone().rotate(45).width > 20, 'rotate options'); const navy = new Image(10, 10).fill(0x00002aff).drawBox(0, 0, 3, 3, 0xc8c800ff); assert(navy.dominantColor({threshold: 0}) === 0x00002aff && navy.dominantColor({ignoreBlack: true}) === navy.dominantColor(true), 'dominantColor options'); @@ -1050,7 +1044,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); rgbaToColor: v => Colors.fromRGBA(v, 0, 0, 255), hslaToColor: v => Colors.fromHSLA(v, 0.5, 0.5, 1), colorToRGBA: v => Colors.toRGBA(v), getPixelAt: v => image().getPixelAt(v, 1), setPixelAt: v => image().setPixelAt(1, 1, v), fill: v => image().fill(v), contain: v => image().contain(v, 4), crop: v => image().crop(v, 0, 2, 2), trim: v => image().trim(v), drawBox: v => image().drawBox(0, 0, v, 2, 0), drawCircle: v => image().drawCircle(2, 2, v, 0), - cropCircle: v => image().cropCircle(false, v), opacity: v => image().opacity(v), red: v => image().red(v), lightness: v => image().lightness(v), + cropCircle: v => image().cropCircle({ feathering: v }), opacity: v => image().opacity(v), red: v => image().red(v), lightness: v => image().lightness(v), composite: v => image().composite(image(), v, 0), hueShift: v => image().hueShift(v), brightness: v => image().brightness(v), gamma: v => image().gamma(v), colorToAlpha: v => image().colorToAlpha(v), rotate: v => image().rotate(v, false), gradient: v => Colors.gradient({0: v, 1: 0}), roundCorners: v => image().roundCorners(v), fisheye: v => image().fisheye(v), encodePNG: v => image().encodePNG(v), encodeJPEG: v => image().encodeJPEG(v), From e07f362bb48b3a08b45f2ff5e1a0d987b49ab8f2 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 16:30:56 +0000 Subject: [PATCH 008/188] remove the positional forms of renderText --- CHANGELOG.md | 2 + src/image.ts | 59 ++------------------------ src/index.ts | 2 +- tests/edge/smoke.mjs | 2 +- tests/effects.js | 2 +- tests/font.js | 96 ++++++++++++++++++++---------------------- tests/jxl-encode.js | 2 +- tests/option-checks.js | 2 +- tests/options.js | 4 -- tests/readme.js | 6 +-- tests/regressions.js | 14 +++--- 11 files changed, 66 insertions(+), 125 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3a462cb..8bc33800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ - **The `max` argument of `cropCircle()` is removed**: `cropCircle(true, feathering)` and `cropCircle({ max: true })` are now `cropCircle({ size: 'cover', feathering })`, and `cropCircle(false)` is `cropCircle()`, the default `size: 'contain'`. +- **The positional forms of `Image.renderText()` are removed**: `Image.renderText(font, size, text, color?, layout?, style?)` and `Image.renderText(font, size, text, options?)` are now `Image.renderText(text, { font, size, color, layout, ...style })`, which takes the same options. A `TextLayout` instance goes to `layout`. + ## 3.20.1 ### Performance diff --git a/src/image.ts b/src/image.ts index a1dddc07..3149a3d5 100644 --- a/src/image.ts +++ b/src/image.ts @@ -383,14 +383,6 @@ export interface SVGOptions { images?: Record; } -/** Options of {@link Image.renderText}: the text color and layout, and its style (see {@link TextStyle}) */ -export interface TextOptions extends TextStyle { - /** The text color. Default: `0xffffffff` */ - color?: Color; - /** The text layout. Default: `new TextLayout()` */ - layout?: TextLayout; -} - /** Options of {@link Image.renderText}: the font and size, the text color and layout, and its style (see {@link TextStyle}) */ export interface RenderTextOptions extends TextStyle { /** @@ -3038,56 +3030,11 @@ export class Image { * outline width on every side * @return The rendered text */ - static renderText(text: string, options: RenderTextOptions): Promise; - /** - * Creates a new image containing the rendered text. - * @param font TrueType (ttf/ttc), OpenType (otf) or WOFF font buffer to use, or several by priority: every - * character comes from the first font that has it (e.g. `[text, emoji]`), the first one sets the line height. - * Color glyphs (COLR layers and paints, CBDT and sbix bitmaps, as in emoji fonts) keep their colors. Text in any - * script is shaped like browsers do (right to left and bidirectional text, Arabic joining, Indic conjuncts, - * marks) - * @param size Font size in pixels (per em) - * @param text Text to render - * @param options Color, layout and style, see {@link TextOptions}; the image grows by the outline width on every - * side - * @return The rendered text - * @deprecated Pass the text first and everything else as options, `Image.renderText(text, { font, size })`. - * This signature will be removed in 4.0 - */ - static renderText(font: BinaryData | BinaryData[], size: number, text: string, options?: TextOptions): Promise; - /** - * Creates a new image containing the rendered text. - * @param font TrueType (ttf/ttc), OpenType (otf) or WOFF font buffer to use, or several by priority: every - * character comes from the first font that has it (e.g. `[text, emoji]`), the first one sets the line height. - * Color glyphs (COLR layers and paints, CBDT and sbix bitmaps, as in emoji fonts) keep their colors. Text in any - * script is shaped like browsers do (right to left and bidirectional text, Arabic joining, Indic conjuncts, - * marks) - * @param size Font size in pixels (per em) - * @param text Text to render - * @param [color=0xffffffff] Text color to use - * @param [layout] The text layout to use - * @param style Outline, see {@link TextStyle}; the image grows by the outline width on every side - * @return The rendered text - * @deprecated Pass the text first and everything else as options, `Image.renderText(text, { font, size })`. - * This signature will be removed in 4.0 - */ - static renderText(font: BinaryData | BinaryData[], size: number, text: string, color?: Color, layout?: TextLayout, style?: TextStyle): Promise; - static async renderText(fontOrText: BinaryData | BinaryData[] | string, sizeOrOptions: number | RenderTextOptions, textArgument?: string, colorOrOptions: Color | TextOptions = 0xffffffff, layoutArgument?: TextLayout, styleArgument: TextStyle = {}): Promise { - let font: BinaryData | BinaryData[], size: number, text: string, options: Omit & { layout?: TextLayoutOptions | TextLayout }; - if (typeof fontOrText === 'string') { - if (!isOptions(sizeOrOptions) || sizeOrOptions.font === undefined) throw new TypeError('renderText(text, options) needs the font in the options'); - ({ font, size, ...options } = sizeOrOptions); - text = fontOrText; - } else { - font = fontOrText; - size = sizeOrOptions as number; - text = textArgument as string; - options = isOptions(colorOrOptions) ? colorOrOptions : { ...styleArgument, color: colorOrOptions, layout: layoutArgument }; - } - const { color = 0xffffffff, strokeWidth = 0, strokeColor = 0x000000ff, hinting = true, features = {}, variations = {} } = options; + static async renderText(text: string, options: RenderTextOptions): Promise { + const { font, color = 0xffffffff, strokeWidth = 0, strokeColor = 0x000000ff, hinting = true, features = {}, variations = {} } = options; const layout = options.layout instanceof TextLayout ? options.layout : new TextLayout(options.layout); const axes = textRenderer.axisValues(variations); - size = finite(size, 'font size'); + const size = finite(options.size, 'font size'); if (size < 0) throw new RangeError(`Invalid font size: ${size}`); if (finite(strokeWidth, 'stroke width') < 0) throw new RangeError('Invalid stroke width'); diff --git a/src/index.ts b/src/index.ts index 3984858a..57a9bd51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,7 @@ import { ImageType } from './image-type.ts'; export { Image, ResizeModes, SVGScaleModes } from './image.ts'; export { Colors } from './colors.ts'; -export type { Color, ColorFunction, ResizeMode, SVGScaleMode, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, SVGOptions, TextOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts'; +export type { Color, ColorFunction, ResizeMode, SVGScaleMode, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, SVGOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts'; export { Frame, FrameDisposalModes } from './frame.ts'; export type { FrameDisposalModeName, FrameOptions } from './frame.ts'; export { GainMap } from './gainmap.ts'; diff --git a/tests/edge/smoke.mjs b/tests/edge/smoke.mjs index 00900cd8..76f7039a 100644 --- a/tests/edge/smoke.mjs +++ b/tests/edge/smoke.mjs @@ -37,7 +37,7 @@ export async function smoke() { }); } await step('text', async () => { - const text = await Image.renderText(font, 24, 'Edge äöü fi', 0x000000ff); + const text = await Image.renderText('Edge äöü fi', { font, size: 24, color: 0x000000ff }); return `${text.width}x${text.height}`; }); await step('svg', async () => { diff --git a/tests/effects.js b/tests/effects.js index e14c9531..404ced1a 100644 --- a/tests/effects.js +++ b/tests/effects.js @@ -18,7 +18,7 @@ const samePixels = require('./util/pixels'); const image = new Image(5 * 128, 4 * 128 + 80).fill(0x404040ff); tiles.forEach((t, i) => image.composite(t, (i % 5) * 128, (i / 5 | 0) * 128)); - image.composite(await Image.renderText(font, 56, 'Outlined', 0xffffffff, undefined, {strokeWidth: 5}), 8, 4 * 128 + 4); + image.composite(await Image.renderText('Outlined', { font, size: 56, color: 0xffffffff, strokeWidth: 5 }), 8, 4 * 128 + 4); const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); diff --git a/tests/font.js b/tests/font.js index afbec5e8..53decb4e 100644 --- a/tests/font.js +++ b/tests/font.js @@ -26,19 +26,19 @@ const panic = message => { // WOFF fonts render like the fonts they compress; WOFF2 ones are refused with a clear message { const text = 'مرحبا بالعالم'; - const ttf = await Image.renderText(await fs.readFile('./tests/fonts/noto naskh arabic subset.ttf'), 48, text); - const woff = await Image.renderText(await fs.readFile('./tests/fonts/noto naskh arabic subset.woff'), 48, text); + const ttf = await Image.renderText(text, { font: await fs.readFile('./tests/fonts/noto naskh arabic subset.ttf'), size: 48 }); + const woff = await Image.renderText(text, { font: await fs.readFile('./tests/fonts/noto naskh arabic subset.woff'), size: 48 }); if (!Buffer.from(ttf.bitmap).equals(Buffer.from(woff.bitmap))) panic('WOFF font doesn\'t render like its TTF'); const woff2 = new Uint8Array(64); woff2.set([0x77, 0x4f, 0x46, 0x32]); - await Image.renderText(woff2, 48, text).then(() => panic('WOFF2 font was accepted'), err => { + await Image.renderText(text, { font: woff2, size: 48 }).then(() => panic('WOFF2 font was accepted'), err => { if (!/WOFF2/.test(err.message)) panic(`unexpected WOFF2 error: ${err.message}`); }); } { - const font = await Image.renderText(await fs.readFile('./tests/fonts/carbon phyber.ttf'), 128, 'ThE qUiCk'); + const font = await Image.renderText('ThE qUiCk', { font: await fs.readFile('./tests/fonts/carbon phyber.ttf'), size: 128 }); const encoded = await font.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) @@ -49,7 +49,7 @@ const panic = message => { } { - const font = await Image.renderText(await fs.readFile('./tests/fonts/ethnocentric rg.ttf'), 128, 'BrOwN fOx'); + const font = await Image.renderText('BrOwN fOx', { font: await fs.readFile('./tests/fonts/ethnocentric rg.ttf'), size: 128 }); const encoded = await font.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) @@ -60,7 +60,7 @@ const panic = message => { } { - const font = await Image.renderText(await fs.readFile('./tests/fonts/opensans bold.ttf'), 128, 'Extra\n-Test\nnewline'); + const font = await Image.renderText('Extra\n-Test\nnewline', { font: await fs.readFile('./tests/fonts/opensans bold.ttf'), size: 128 }); const encoded = await font.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) @@ -72,7 +72,7 @@ const panic = message => { { // OpenType font with CFF outlines - const font = await Image.renderText(await fs.readFile('./tests/fonts/sourcesans3 regular.otf'), 64, 'Quartz glyph job, vex!'); + const font = await Image.renderText('Quartz glyph job, vex!', { font: await fs.readFile('./tests/fonts/sourcesans3 regular.otf'), size: 64 }); const encoded = await font.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) @@ -85,7 +85,7 @@ const panic = message => { { const font = await fs.readFile('./tests/fonts/opensans bold.ttf'); // (unhinted: the layout places glyphs with subpixel precision) - const render = (text, options) => Image.renderText(font, 40, text, 0xffffffff, new TextLayout(options), {hinting: false}); + const render = (text, options) => Image.renderText(text, { font, size: 40, color: 0xffffffff, layout: new TextLayout(options), hinting: false }); // first column with ink in the given row const inkStart = (image, y) => { for (let x = 0; x < image.width; x++) @@ -106,7 +106,7 @@ const panic = message => { panic('layout: horizontal alignment has no effect'); // kerning pulls 'To' together (without it, the pair is wider than both glyphs' ink) - const big = text => Image.renderText(font, 200, text, 0xffffffff, undefined, {hinting: false}); + const big = text => Image.renderText(text, { font, size: 200, color: 0xffffffff, hinting: false }); if ((await big('To')).width > (await big('T')).width + (await big('o')).width - 5) panic('layout: kerning has no effect'); // wrapping @@ -226,17 +226,17 @@ const panic = message => { const cols = columns(image).filter(c => c.split(',').some(v => +v)); return new Set(cols).size; }; - const plain = await Image.renderText(data, 11, 'lllll', 0x000000ff, undefined, {hinting: false}); - const hinted = await Image.renderText(data, 11, 'lllll', 0x000000ff); + const plain = await Image.renderText('lllll', { font: data, size: 11, color: 0x000000ff, hinting: false }); + const hinted = await Image.renderText('lllll', { font: data, size: 11, color: 0x000000ff }); if (repeats(hinted) >= repeats(plain)) panic(`hinting: repeated glyphs should render identically (${repeats(hinted)} vs ${repeats(plain)} distinct columns)`); - const word = await Image.renderText(data, 11, 'Hamburg', 0x000000ff); - if (blurry(word) >= blurry(await Image.renderText(data, 11, 'Hamburg', 0x000000ff, undefined, {hinting: false}))) panic('hinting: text should be crisper'); + const word = await Image.renderText('Hamburg', { font: data, size: 11, color: 0x000000ff }); + if (blurry(word) >= blurry(await Image.renderText('Hamburg', { font: data, size: 11, color: 0x000000ff, hinting: false }))) panic('hinting: text should be crisper'); // fonts without TrueType instructions (CFF, or TrueType without them) render as without hinting for (const file of ['sourcesans3 regular.otf', 'carbon phyber.ttf']) { const font = await fs.readFile(`./tests/fonts/${file}`); - const on = await Image.renderText(font, 11, 'Hamburg', 0x000000ff); - const off = await Image.renderText(font, 11, 'Hamburg', 0x000000ff, undefined, {hinting: false}); + const on = await Image.renderText('Hamburg', { font, size: 11, color: 0x000000ff }); + const off = await Image.renderText('Hamburg', { font, size: 11, color: 0x000000ff, hinting: false }); if (!Buffer.from(on.bitmap).equals(Buffer.from(off.bitmap))) panic(`hinting: ${file} has no instructions and should render unchanged`); } } @@ -247,7 +247,7 @@ const panic = message => { const sans = await fs.readFile('./tests/fonts/sourcesans3 regular.otf'); for (const [file, target] of [['noto color emoji subset.ttf', 'emoji-cbdt'], ['twemoji mozilla subset.ttf', 'emoji-colr'], ['noto colrv1 subset.ttf', 'emoji-colrv1']]) { const emoji = await fs.readFile(`./tests/fonts/${file}`); - const image = await Image.renderText([sans, emoji], 48, 'Hi 😀🔥 👍🏽 🇩🇪 1️⃣ ❤️ 👨‍👩‍👧', {color: 0x000000ff}); + const image = await Image.renderText('Hi 😀🔥 👍🏽 🇩🇪 1️⃣ ❤️ 👨‍👩‍👧', { font: [sans, emoji], size: 48, color: 0x000000ff }); const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile(`./tests/targets/${target}.png`, encoded); @@ -259,28 +259,28 @@ const panic = message => { if (colored < 500) panic(`${file}: emoji should keep their colors`); // joiners and variation selectors take no room, even where the font has glyphs for them - const plain = await Image.renderText([sans, emoji], 48, '😀😀'), joined = await Image.renderText([sans, emoji], 48, '😀\u200d\ufe0f😀'); + const plain = await Image.renderText('😀😀', { font: [sans, emoji], size: 48 }), joined = await Image.renderText('😀\u200d\ufe0f😀', { font: [sans, emoji], size: 48 }); if (plain.width !== joined.width) panic(`${file}: invisible characters should take no room (${plain.width} vs ${joined.width})`); // sequences are one glyph (GSUB ligatures): skin tones, flags, keycaps, joined ones - const one = (await Image.renderText(emoji, 48, '👍')).width; + const one = (await Image.renderText('👍', { font: emoji, size: 48 })).width; for (const sequence of ['👍🏽', '🇩🇪', '1️⃣', '👨‍👩‍👧']) - if ((await Image.renderText([sans, emoji], 48, sequence)).width > one * 1.2) panic(`${file}: ${sequence} should be a single glyph`); + if ((await Image.renderText(sequence, { font: [sans, emoji], size: 48 })).width > one * 1.2) panic(`${file}: ${sequence} should be a single glyph`); // the emoji variation selector picks the color glyph over the text font's heart - const heart = await Image.renderText([sans, emoji], 48, '❤️', {color: 0x000000ff}); + const heart = await Image.renderText('❤️', { font: [sans, emoji], size: 48, color: 0x000000ff }); let red = 0; for (let i = 0; i < heart.bitmap.length; i += 4) if (heart.bitmap[i + 3] > 128 && heart.bitmap[i] > 150 && heart.bitmap[i + 1] < 100) red++; if (red < 100) panic(`${file}: ❤️ should be the red emoji`); // characters the emoji font doesn't have come from the next font (the first one still sets the line height) - const fallback = await Image.renderText([emoji, sans], 48, 'Hi'), direct = await Image.renderText(sans, 48, 'Hi'); + const fallback = await Image.renderText('Hi', { font: [emoji, sans], size: 48 }), direct = await Image.renderText('Hi', { font: sans, size: 48 }); const ink = image => image.bitmap.reduce((sum, v, i) => (i & 3) === 3 ? sum + v : sum, 0); if (fallback.width !== direct.width || ink(fallback) !== ink(direct)) panic(`${file}: missing characters should come from the next font`); } // fonts with only bitmap glyphs render on their own - const bitmapOnly = await Image.renderText(await fs.readFile('./tests/fonts/noto color emoji subset.ttf'), 32, '🔥'); + const bitmapOnly = await Image.renderText('🔥', { font: await fs.readFile('./tests/fonts/noto color emoji subset.ttf'), size: 32 }); if (bitmapOnly.width < 20) panic('bitmap font should render on its own'); } @@ -288,17 +288,17 @@ const panic = message => { // every COLR version 1 paint feature: gradients, transforms, composite modes, the text color const font = await fs.readFile('./tests/fonts/colrv1 paints.ttf'); const glyphs = Array.from({length: 17}, (_, k) => String.fromCodePoint(0xe000 + k)).join(''); - const image = await Image.renderText(font, 48, glyphs, {color: 0x00aaaaff}); + const image = await Image.renderText(glyphs, { font, size: 48, color: 0x00aaaaff }); const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/colrv1-paints.png', encoded); if (!await samePixels(await fs.readFile('./tests/targets/colrv1-paints.png'), encoded)) panic('colrv1-paints doesn\'t match'); // the linear gradient runs from red (left) to blue (right), the last glyph's square is in the text color - const linear = await Image.renderText(font, 100, '\ue000'); + const linear = await Image.renderText('\ue000', { font, size: 100 }); const [lr, , lb] = linear.getRGBAAt(Math.round(linear.width * 0.15), linear.height >> 1), [rr, , rb] = linear.getRGBAAt(Math.round(linear.width * 0.85), linear.height >> 1); if (!(lr > 150 && lb < 100 && rb > 150 && rr < 100)) panic(`colrv1: linear gradient should go from red to blue (${lr},${lb} -> ${rr},${rb})`); - const foreground = await Image.renderText(font, 100, '\ue010', {color: 0x00aaaaff}); + const foreground = await Image.renderText('\ue010', { font, size: 100, color: 0x00aaaaff }); const [fr, fg, fb, fa] = foreground.getRGBAAt(Math.round(foreground.width * 0.12), foreground.height >> 1); if (fr > 2 || Math.abs(fg - 0xaa) > 2 || Math.abs(fb - 0xaa) > 2 || fa < 253) panic(`colrv1: layers in the text color should take it (${fr},${fg},${fb},${fa})`); @@ -328,27 +328,27 @@ const panic = message => { }; let glyph = 0xe011; for (const [name, blend] of Object.entries(blends)) { - const image = await Image.renderText(font, 100, String.fromCodePoint(glyph++)); + const image = await Image.renderText(String.fromCodePoint(glyph++), { font, size: 100 }); const actual = image.getRGBAAt(image.width >> 1, 50), expected = blend(backdrop, source).map(v => Math.round(v * 255)); if (expected.some((v, i) => Math.abs(v - actual[i]) > 1) || actual[3] !== 255) panic(`colrv1 ${name}: ${actual} instead of ${expected}`); } // a radial gradient whose circles touch (red at the left edge of the inner one, blue around the outer one), a // solid paint (green) inside a disc, and a glyph without a clip box sized by its outlines - const touching = await Image.renderText(font, 100, '\ue01a'); + const touching = await Image.renderText('\ue01a', { font, size: 100 }); const [tr, , tb] = touching.getRGBAAt(31, 50), [, , ob] = touching.getRGBAAt(88, 50); if (!(tr > 200 && tb < 50 && ob > 200)) panic(`colrv1: touching radial gradient (${tr},${tb} / ${ob})`); - const solidFill = await Image.renderText(font, 100, '\ue01b'); + const solidFill = await Image.renderText('\ue01b', { font, size: 100 }); if (solidFill.getRGBAAt(solidFill.width >> 1, 50).join() !== '0,153,0,255' || solidFill.getRGBAAt(12, 85)[3] !== 0) panic(`colrv1: a solid paint should fill the disc only (${solidFill.getRGBAAt(solidFill.width >> 1, 50)})`); - const unclipped = await Image.renderText(font, 100, '\ue01c'); + const unclipped = await Image.renderText('\ue01c', { font, size: 100 }); if (unclipped.width < 80 || unclipped.width > 110) panic(`colrv1: a glyph without a clip box should be sized by its outlines (${unclipped.width})`); } { // sbix bitmaps: the PNG placed by its origin offset (8, -4 at 64 ppem), and a 'dupe' of it const font = await fs.readFile('./tests/fonts/sbix.ttf'); - const square = await Image.renderText(font, 64, '\ue000'), dupe = await Image.renderText(font, 64, '\ue001'); + const square = await Image.renderText('\ue000', { font, size: 64 }), dupe = await Image.renderText('\ue001', { font, size: 64 }); if (!Buffer.from(square.bitmap).equals(Buffer.from(dupe.bitmap))) panic('sbix: a dupe should render as the glyph it refers to'); // the baseline is at the ascender (0.9 em), the bitmap's bottom 4 pixels below it, its left edge at 8 const [r, g, b] = square.getRGBAAt(8 + 24, Math.ceil(0.9 * 64) + 4 - 24), [cr, , cb] = square.getRGBAAt(8 + 4, Math.ceil(0.9 * 64) + 4 - 44); @@ -360,10 +360,10 @@ const panic = message => { // text without anything to show renders a transparent pixel wide, it doesn't throw const font = await fs.readFile('./tests/fonts/sourcesans3 regular.otf'); for (const text of ['', ' ', '\u200b\ufe0f']) - if ((await Image.renderText(font, 32, text)).width !== 1) panic(`empty text: ${JSON.stringify(text)} should render a pixel wide`); + if ((await Image.renderText(text, { font, size: 32 })).width !== 1) panic(`empty text: ${JSON.stringify(text)} should render a pixel wide`); // combining sequences are composed where the font has the composed character - const composed = await Image.renderText(font, 32, 'Café Müller'), decomposed = await Image.renderText(font, 32, 'Cafe\u0301 Mu\u0308ller'); + const composed = await Image.renderText('Café Müller', { font, size: 32 }), decomposed = await Image.renderText('Cafe\u0301 Mu\u0308ller', { font, size: 32 }); if (!Buffer.from(composed.bitmap).equals(Buffer.from(decomposed.bitmap))) panic('combining sequences should render like the composed characters'); } @@ -373,7 +373,7 @@ const panic = message => { const fonts = await Promise.all(['opensans bold.ttf', 'noto sans hebrew subset.ttf', 'noto naskh arabic subset.ttf', 'noto sans devanagari subset.ttf'] .map(name => fs.readFile(`./tests/fonts/${name}`))); const text = 'Hi שָׁלוֹם עוֹלָם (2024) and مرحبا بالعالم!\nمرحبا بالعالم، هذا نص (2024)!\nनमस्ते क्षत्रिय र्क्ष'; - const font = await Image.renderText(fonts, 40, text, 0xffffffff, new TextLayout({maxWidth: 420, horizontalAlign: 'right'})); + const font = await Image.renderText(text, { font: fonts, size: 40, color: 0xffffffff, layout: new TextLayout({maxWidth: 420, horizontalAlign: 'right'}) }); const encoded = await font.encodePNG(1, {creationTime: 0, software: ''}); if (process.env.OVERWRITE_TEST) @@ -383,8 +383,8 @@ const panic = message => { if (!await samePixels(desired, encoded)) panic('bidirectional text doesn\'t match'); // a right to left paragraph's line starts on the right: its first word is the rightmost - const line = await Image.renderText(fonts, 40, 'مرحبا Hi', 0xffffffff); - const arabic = await Image.renderText(fonts, 40, 'مرحبا', 0xffffffff); + const line = await Image.renderText('مرحبا Hi', { font: fonts, size: 40, color: 0xffffffff }); + const arabic = await Image.renderText('مرحبا', { font: fonts, size: 40, color: 0xffffffff }); const right = line.clone().crop(line.width - arabic.width, 0, arabic.width, line.height); if (!await samePixels(await arabic.encodePNG(1, {creationTime: 0, software: ''}), await right.encodePNG(1, {creationTime: 0, software: ''}))) panic('the right to left paragraph should start on the right'); @@ -394,15 +394,15 @@ const panic = message => { // OpenType features: on (small caps, fractions), off (default ligatures); unknown ones and defaults change nothing const font = await fs.readFile('./tests/fonts/sourcesans3 regular.otf'); const text = '1111 office 2/3'; - const base = await Image.renderText(font, 32, text); + const base = await Image.renderText(text, { font, size: 32 }); const same = (a, b) => a.width === b.width && a.height === b.height && a.bitmap.every((v, i) => v === b.bitmap[i]); for (const features of [{smcp: true}, {liga: false}, {frac: true}]) - if (same(base, await Image.renderText(font, 32, text, {features}))) panic(`features ${JSON.stringify(features)} should change the text`); + if (same(base, await Image.renderText(text, { font, size: 32, features }))) panic(`features ${JSON.stringify(features)} should change the text`); for (const features of [{zzzz: true}, {liga: true}, {kern: 1}]) - if (!same(base, await Image.renderText(font, 32, text, {features}))) panic(`features ${JSON.stringify(features)} shouldn't change the text`); + if (!same(base, await Image.renderText(text, { font, size: 32, features }))) panic(`features ${JSON.stringify(features)} shouldn't change the text`); for (const features of [{toolong: true}, {salt: 1.5}, {salt: -1}, {'ab': true}]) { let error = null; - try { await Image.renderText(font, 32, text, {features}); } catch (err) { error = err; } + try { await Image.renderText(text, { font, size: 32, features }); } catch (err) { error = err; } if (!(error instanceof RangeError)) panic(`features ${JSON.stringify(features)} should be rejected`); } } @@ -413,7 +413,7 @@ const panic = message => { const flex = await fs.readFile('./tests/fonts/resvg/RobotoFlex.subset.ttf'), text = 'Bold Thin'; const same = (a, b) => a.width === b.width && a.height === b.height && a.bitmap.every((v, i) => v === b.bitmap[i]); const ink = image => image.bitmap.reduce((sum, v, i) => (i & 3) === 3 ? sum + v : sum, 0); - const render = variations => Image.renderText(flex, 32, text, {variations}); + const render = variations => Image.renderText(text, { font: flex, size: 32, variations }); const regular = await render({}), bold = await render({weight: 900}), thin = await render({weight: 100}); if (!(ink(thin) < ink(regular) && ink(regular) < ink(bold))) panic('variable font: weight should change the strokes'); if (!same(bold, await render({wght: 900}))) panic('variable font: weight and wght should be the same'); @@ -425,7 +425,7 @@ const panic = message => { if (same(regular, await render({GRAD: 150}))) panic('variable font: custom axes by tag'); if (!same(regular, await render({ZZZZ: 1}))) panic('variable font: axes it doesn\'t have should be ignored'); const plain = await fs.readFile('./tests/fonts/sourcesans3 regular.otf'); - if (!same(await Image.renderText(plain, 32, text), await Image.renderText(plain, 32, text, {variations: {weight: 900}}))) panic('fonts without axes shouldn\'t change'); + if (!same(await Image.renderText(text, { font: plain, size: 32 }), await Image.renderText(text, { font: plain, size: 32, variations: {weight: 900} }))) panic('fonts without axes shouldn\'t change'); for (const variations of [{wieght: 700}, {weight: NaN}, {weight: '700'}]) { let error = null; try { await render(variations); } catch (err) { error = err; } @@ -433,19 +433,15 @@ const panic = message => { } } - // renderText(text, options) renders like the deprecated renderText(font, size, text, ...) (removed in 4.0) + // a plain layout object is a TextLayout { const font = await fs.readFile('./tests/fonts/sourcesans3 regular.otf'); const text = 'Hello wrapped world'; const layout = new TextLayout({maxWidth: 90, horizontalAlign: 'center'}); - const cases = [ - [await Image.renderText(font, 32, text), await Image.renderText(text, {font, size: 32})], - [await Image.renderText(font, 24, text, 0xff8800ff, layout), await Image.renderText(text, {font, size: 24, color: 0xff8800ff, layout})], - [await Image.renderText(font, 24, text, 0xff8800ff, layout), await Image.renderText(text, {font, size: 24, color: 0xff8800ff, layout: {maxWidth: 90, horizontalAlign: 'center'}})], - [await Image.renderText(font, 24, text, {strokeWidth: 2, strokeColor: 0xff0000ff, features: {smcp: true}}), await Image.renderText(text, {font, size: 24, strokeWidth: 2, strokeColor: 0xff0000ff, features: {smcp: true}})], - [await Image.renderText([font, font], 20, text), await Image.renderText(text, {font: [font, font], size: 20})] - ]; - cases.forEach(([old, now], i) => { if (old.width !== now.width || old.height !== now.height || !Buffer.from(old.bitmap).equals(Buffer.from(now.bitmap))) panic(`renderText(text, options) case ${i} differs from the old signature`); }); + const same = (a, b) => a.width === b.width && a.height === b.height && Buffer.from(a.bitmap).equals(Buffer.from(b.bitmap)); + const withLayout = await Image.renderText(text, {font, size: 24, color: 0xff8800ff, layout}); + const withOptions = await Image.renderText(text, {font, size: 24, color: 0xff8800ff, layout: {maxWidth: 90, horizontalAlign: 'center'}}); + if (!same(withLayout, withOptions)) panic('renderText: a layout object should render like a TextLayout'); for (const options of [undefined, {size: 20}, {font}, {font, size: NaN}]) { let error = null; try { await Image.renderText(text, options); } catch (err) { error = err; } diff --git a/tests/jxl-encode.js b/tests/jxl-encode.js index 6cc01337..43059066 100644 --- a/tests/jxl-encode.js +++ b/tests/jxl-encode.js @@ -168,7 +168,7 @@ const codestream = jxl => { // patches (effort 2): repeated glyphs of text on a flat background are found, stored in a reference frame and // added back { - const text = await Image.renderText(await fs.readFile('./tests/fonts/opensans bold.ttf'), 18, 'abba cabbage\nbeach bag cab\nabba cabbage', 0x202020ff); + const text = await Image.renderText('abba cabbage\nbeach bag cab\nabba cabbage', { font: await fs.readFile('./tests/fonts/opensans bold.ttf'), size: 18, color: 0x202020ff }); const page = new Image(text.width + 16, text.height + 16).fill(0xf4f4f4ff).composite(text, 8, 8); const {findPatches} = await import('../dist/codecs/jxl/patches-enc.js'); const w = page.width, h = page.height, planes = [0, 1, 2].map(c => Float32Array.from({length: w * h}, (_, i) => page.bitmap[i * 4 + c] / 255)); diff --git a/tests/option-checks.js b/tests/option-checks.js index 8e4101d6..8b11e4cb 100644 --- a/tests/option-checks.js +++ b/tests/option-checks.js @@ -53,7 +53,7 @@ const rejects = async (name, fn, Type, pattern) => { await rejects('Animation loopCount 1.5', () => new Animation([new Frame(8, 8)], {loopCount: 1.5}), RangeError, /loop count/); await rejects('dominantColor threshold NaN', () => image.dominantColor({threshold: NaN}), RangeError, /threshold/); await rejects('renderSVG size NaN', () => Image.renderSVG('', {size: NaN}), RangeError, /SVG size/); - await rejects('renderText size -1', () => Image.renderText(new Uint8Array(0), -1, 'x'), RangeError, /font size/); + await rejects('renderText size -1', () => Image.renderText('x', { font: new Uint8Array(0), size: -1 }), RangeError, /font size/); const png = await image.encodePNG(); for (const maxPixels of [NaN, -1, 0]) { await rejects(`Image.decode maxPixels ${maxPixels}`, () => Image.decode(png, {maxPixels}), RangeError, /maxPixels/); diff --git a/tests/options.js b/tests/options.js index 8c9e660c..62808d4d 100644 --- a/tests/options.js +++ b/tests/options.js @@ -46,10 +46,6 @@ const same = (name, a, b) => { const svg = await fs.readFile('./tests/svgs/crown.svg'); same('renderSVG', await Image.renderSVG(svg, 40, SVGScaleModes.WIDTH), await Image.renderSVG(svg, {size: 40, mode: SVGScaleModes.WIDTH})); - const font = await fs.readFile('./tests/fonts/opensans bold.ttf'); - same('renderText', await Image.renderText(font, 24, 'Options', 0x3366ffff, undefined, {strokeWidth: 2, strokeColor: 0xffffffff}), - await Image.renderText(font, 24, 'Options', {color: 0x3366ffff, strokeWidth: 2, strokeColor: 0xffffffff})); - // frames and animations const a = new Frame(8, 6, 40, 2, 3, 'background'), b = new Frame(8, 6, {duration: 40, xOffset: 2, yOffset: 3, disposalMode: 'background'}); const c = Frame.from(logo, {duration: 40, xOffset: 2, yOffset: 3, disposalMode: 'background'}); diff --git a/tests/readme.js b/tests/readme.js index b0eb45a5..1372efce 100644 --- a/tests/readme.js +++ b/tests/readme.js @@ -37,7 +37,7 @@ const font = name => fs.readFileSync('./tests/fonts/' + name); image.composite(glass, card.x, card.y); const text = async (fonts, size, string, color, x, y) => { - const rendered = await Image.renderText(fonts, size, string, {color}); + const rendered = await Image.renderText(string, { font: fonts, size, color }); image.composite(rendered, x, y); return rendered; }; @@ -68,13 +68,13 @@ const font = name => fs.readFileSync('./tests/fonts/' + name); image.composite(track, cx - 7, chart.top); const bar = new Image(14, y1 - y0).fill((x, y) => gradient((chart.max - (y0 + y - chart.top) / chart.height * (chart.max - chart.min) - chart.min) / (chart.max - chart.min))); image.composite(bar.roundCorners(7), cx - 7, y0); - const label = await Image.renderText(regular, 20, day, {color: 0xffffffc0}); + const label = await Image.renderText(day, { font: regular, size: 20, color: 0xffffffc0 }); image.composite(label, cx - label.width / 2, chart.top + chart.height + 10); } // a divider, then greetings in a few scripts image.drawBox(left, card.y + 446, card.width - 80, 1, 0xffffff40); - const greeting = await Image.renderText(scripts, 26, 'Bom dia · مرحبا · नमस्ते 😀', {color: 0xffffffe0}); + const greeting = await Image.renderText('Bom dia · مرحبا · नमस्ते 😀', { font: scripts, size: 26, color: 0xffffffe0 }); image.composite(greeting, left, card.y + 446 + (card.height - 446 - greeting.height) / 2); image.roundCorners(24); diff --git a/tests/regressions.js b/tests/regressions.js index bf98dbc3..b59b9276 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -810,8 +810,8 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // outlined text grows by the stroke width and puts the stroke color around the glyphs { const font = require('fs').readFileSync('./tests/fonts/opensans bold.ttf'); - const plain = await Image.renderText(font, 40, 'Hi'); - const outlined = await Image.renderText(font, 40, 'Hi', 0xffffffff, undefined, {strokeWidth: 3, strokeColor: 0xff0000ff}); + const plain = await Image.renderText('Hi', { font, size: 40 }); + const outlined = await Image.renderText('Hi', { font, size: 40, color: 0xffffffff, strokeWidth: 3, strokeColor: 0xff0000ff }); assert(outlined.width === plain.width + 6 && outlined.height === plain.height + 6, 'stroke should grow the image'); let red = 0, white = 0; for (const [, , color] of outlined.pixels()) { @@ -821,8 +821,8 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); assert(red > 100 && white > 100, `outlined text should contain both colors (${red} red, ${white} white)`); // the stroke goes around translucent text, it doesn't show through it - const translucent = await Image.renderText(font, 40, 'Hi', 0x0000ff80, undefined, {strokeWidth: 3, strokeColor: 0xff0000ff}); - const fill = await Image.renderText(font, 40, 'Hi', 0x0000ff80); + const translucent = await Image.renderText('Hi', { font, size: 40, color: 0x0000ff80, strokeWidth: 3, strokeColor: 0xff0000ff }); + const fill = await Image.renderText('Hi', { font, size: 40, color: 0x0000ff80 }); let inside = 0, tinted = 0; // pixels the glyphs cover fully (the stroked image is 3 pixels larger on every side) for (const [x, y, color] of fill.pixels()) { @@ -922,7 +922,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const random = () => (seed = Math.imul(seed, 1103515245) + 12345 >>> 0) / 2 ** 32; // (a damaged copy of the Noto-COLRv1 subset with 3329 contours of decreasing end points in a glyph ran out of memory) const start = Date.now(); - await Image.renderText(fs.readFileSync('./tests/fonts/damaged noto colrv1 subset.ttf'), 40, '😀🔥👍❤️').catch(err => assert(/^Font: /.test(err.message), err.message)); + await Image.renderText('😀🔥👍❤️', { font: fs.readFileSync('./tests/fonts/damaged noto colrv1 subset.ttf'), size: 40 }).catch(err => assert(/^Font: /.test(err.message), err.message)); assert(Date.now() - start < 3000, 'the damaged Noto-COLRv1 subset took too long'); for (const file of ['sourcesans3 regular.otf', 'opensans bold.ttf', 'noto colrv1 subset.ttf', 'noto color emoji subset.ttf']) { const original = fs.readFileSync(`./tests/fonts/${file}`); @@ -934,7 +934,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); } const start = Date.now(); try { - await Image.renderText(data, 40, 'Hi office 😀🔥 👍🏽 🇩🇪 1️⃣ e\u0301'); + await Image.renderText('Hi office 😀🔥 👍🏽 🇩🇪 1️⃣ e\u0301', { font: data, size: 40 }); } catch (err) { assert(/^Font: /.test(err.message), `${file} #${k}: unexpected error for a damaged font: ${err.message}`); } @@ -1063,7 +1063,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); if (!(error instanceof RangeError)) panic(`${name}(${value}) should throw a RangeError, got ${error}`); } const font = require('fs').readFileSync('./tests/fonts/opensans bold.ttf'); - for (const [name, call] of Object.entries({size: v => Image.renderText(font, v, 'a'), color: v => Image.renderText(font, 12, 'a', v), + for (const [name, call] of Object.entries({size: v => Image.renderText('a', { font, size: v }), color: v => Image.renderText('a', { font, size: 12, color: v }), maxWidth: v => new TextLayout({maxWidth: v})})) if (!await Promise.resolve().then(() => call(NaN)).then(() => false, e => e instanceof RangeError)) panic(`renderText: a NaN ${name} should throw a RangeError`); // (no limit is a valid layout) From df000a49336fc75012d03d2b335b104a130dba63 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 16:32:11 +0000 Subject: [PATCH 009/188] remove the deprecated animation encoder options and frames --- CHANGELOG.md | 2 ++ src/animation-encoder.ts | 41 +------------------------------------- src/index.ts | 1 - tests/animation-encoder.js | 3 +-- tests/regressions.js | 2 +- 5 files changed, 5 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bc33800..95976783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ - **The positional forms of `Image.renderText()` are removed**: `Image.renderText(font, size, text, color?, layout?, style?)` and `Image.renderText(font, size, text, options?)` are now `Image.renderText(text, { font, size, color, layout, ...style })`, which takes the same options. A `TextLayout` instance goes to `layout`. +- **The deprecated animation encoder option types and `AnimationEncoder.frames` are removed**: `AnimationEncoderOptions`, `WebPAnimationEncoderOptions`, `GIFAnimationEncoderOptions` and `APNGAnimationEncoderOptions` are `AnimationEncodeOptions`, `WebPAnimationEncodeOptions`, `GIFEncodeOptions` and `APNGEncodeOptions`, each with `AnimationOptions` for the `loopCount`, and `encoder.frames` is `encoder.frameCount`. + ## 3.20.1 ### Performance diff --git a/src/animation-encoder.ts b/src/animation-encoder.ts index 4bb281cf..9c62517f 100644 --- a/src/animation-encoder.ts +++ b/src/animation-encoder.ts @@ -7,41 +7,11 @@ import { Optimizer, Compositor, InputFrame, OptimizeOptions, OptimizedFrame, cov import { compressAsync, decompressAsync } from './codecs/zlib.ts'; import { resolveMetadata, pngText, exifData, gifComments } from './metadata.ts'; import { finite, compressionLevel } from './util/options.ts'; -import type { AnimationOptions, AnimationEncodeOptions, WebPAnimationEncodeOptions, GIFEncodeOptions, APNGEncodeOptions } from './animation.ts'; +import type { AnimationOptions, WebPAnimationEncodeOptions, GIFEncodeOptions, APNGEncodeOptions } from './animation.ts'; // (ImageScript, like GIF, counts repetitions: -1 forever, 0 play once; APNG and WebP count plays, 0 forever) const toPlays = (loopCount: number): number => loopCount < 0 || loopCount === Infinity ? 0 : loopCount + 1; -/** - * The options all animation encoders of {@link Animation.webpEncoder} and the others have - * @deprecated {@link Animation.webpEncoder} and the others take the options of {@link Animation.encodeWebP} and the - * others ({@link AnimationEncodeOptions}) with the {@link AnimationOptions.loopCount}, this will be removed in 4.0 - * @group Deprecated - */ -export type AnimationEncoderOptions = AnimationEncodeOptions & AnimationOptions; - -/** - * Options of {@link Animation.webpEncoder} - * @deprecated Use {@link WebPAnimationEncodeOptions}, with {@link AnimationOptions.loopCount}: this will be removed - * in 4.0 - * @group Deprecated - */ -export type WebPAnimationEncoderOptions = WebPAnimationEncodeOptions & AnimationOptions; - -/** - * Options of {@link Animation.gifEncoder} - * @deprecated Use {@link GIFEncodeOptions}, with {@link AnimationOptions.loopCount}: this will be removed in 4.0 - * @group Deprecated - */ -export type GIFAnimationEncoderOptions = GIFEncodeOptions & AnimationOptions; - -/** - * Options of {@link Animation.apngEncoder} - * @deprecated Use {@link APNGEncodeOptions}, with {@link AnimationOptions.loopCount}: this will be removed in 4.0 - * @group Deprecated - */ -export type APNGAnimationEncoderOptions = APNGEncodeOptions & AnimationOptions; - // a format's part: takes the frames in order once they can't change anymore, then makes the file interface Format { readonly optimize: OptimizeOptions; @@ -80,15 +50,6 @@ export class AnimationEncoder { return this.count; } - /** - * How many frames were added - * @deprecated Use {@link AnimationEncoder.frameCount}, `frames` will be removed in 4.0 - * @group Deprecated - */ - get frames(): number { - return this.count; - } - /** * Adds the next frame: a {@link Frame} (with its duration, offsets and disposal) or an image (a frame of 100 ms by * default). The options override the frame's. The pixels are copied right away, the image can change afterwards diff --git a/src/index.ts b/src/index.ts index 57a9bd51..20d20630 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,6 @@ export { GIF } from './gif.ts'; export { Animation } from './animation.ts'; export type { AnimationOptions, AnimationDecodeOptions, AnimationEncodeOptions, WebPAnimationEncodeOptions, GIFEncodeOptions, APNGEncodeOptions } from './animation.ts'; export { AnimationEncoder } from './animation-encoder.ts'; -export type { AnimationEncoderOptions, WebPAnimationEncoderOptions, GIFAnimationEncoderOptions, APNGAnimationEncoderOptions } from './animation-encoder.ts'; export { TextLayout } from './text-layout.ts'; export type { TextLayoutOptions, WrapStyle, HorizontalAlign, VerticalAlign } from './text-layout.ts'; export { ImageType } from './image-type.ts'; diff --git a/tests/animation-encoder.js b/tests/animation-encoder.js index c8997a9c..3a14e8e5 100644 --- a/tests/animation-encoder.js +++ b/tests/animation-encoder.js @@ -30,8 +30,7 @@ const metadata = {creationTime: 0, software: ''}; ['apng', {}, a => a.encodeAPNG({optimize, metadata})]]) { const encoder = Animation[`${format}Encoder`](animation.width, animation.height, {...options, optimize, metadata, loopCount: animation.loopCount}); for (const frame of animation) await encoder.add(frame); - if (encoder.frames !== animation.length) panic(`${name} ${format}: ${encoder.frames} frames added`); - if (encoder.frameCount !== encoder.frames) panic(`${name} ${format}: frameCount ${encoder.frameCount} != frames ${encoder.frames}`); + if (encoder.frameCount !== animation.length) panic(`${name} ${format}: ${encoder.frameCount} frames added`); const file = await encoder.finish(), expected = await reference(animation); if (equal(file, expected)) continue; // unoptimized WebPs with frames disposed to the previous one: the frames from there on show the canvas, diff --git a/tests/regressions.js b/tests/regressions.js index b59b9276..8582c5f1 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -440,7 +440,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); assert(threw, 'cropCircle({size}) validates'); } - // the encoders take the options of the encode functions (the *AnimationEncoderOptions types are deprecated aliases, removed in 4.0) + // the encoders take the options of the encode functions await withFrozenTime(async () => { const {Animation} = require('..'); const frames = () => [Frame.from(new Image(6, 6).fill(0x336699ff), 100), Frame.from(new Image(6, 6).fill(0x996633ff), 100)]; From e0340aff30fc01c55f564aef8351e909a1a1e6d2 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 16:38:06 +0000 Subject: [PATCH 010/188] reject arguments that are not options in cropCircle and renderText --- CHANGELOG.md | 1 + src/image.ts | 5 ++++- src/util/options.ts | 9 ++++++++ tests/options-objects.js | 44 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 tests/options-objects.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 95976783..1e0caa61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Breaking changes +- **Methods that only take options refuse anything else**: `cropCircle()` and `Image.renderText()` throw a `TypeError` that names the working form (e.g. `cropCircle({ size: 'cover' })`) for the 3.x calls that are gone, like `cropCircle(true)` or `Image.renderText(font, 32, 'Hi')`, instead of ignoring them. - **The deprecated resize, SVG and frame disposal constants are removed**: `Image.RESIZE_NEAREST_NEIGHBOR`, `Image.RESIZE_BILINEAR`, `Image.RESIZE_BICUBIC`, `Image.RESIZE_AREA`, `Image.SVG_MODE_SCALE`, `Image.SVG_MODE_WIDTH`, `Image.SVG_MODE_HEIGHT` and `Frame.DISPOSAL_*` are gone: use `ResizeModes`, `SVGScaleModes` and `FrameDisposalModes` instead. `ResizeModes.AUTO` (and `Image.RESIZE_AUTO`) is removed too: pass only the width or the height, `image.resize({ width: 256 })`. The old values are no longer accepted either: `'RESIZE_BILINEAR'`, the numbers 1-3 for SVG scale modes and the numeric disposal ids 0-3. - **`absolute` is removed from `opacity()`, `red()`, `green()`, `blue()`, `lightness()` and `saturation()`**: they only scale now, `image.red(0.5)` (and `AdjustOptions`, the type of their options, is removed). For a fixed color, use `image.recolor()`. diff --git a/src/image.ts b/src/image.ts index 3149a3d5..f39af07d 100644 --- a/src/image.ts +++ b/src/image.ts @@ -33,7 +33,7 @@ import { resample, BOX, TRIANGLE, CUBIC, type Region } from './util/resample.ts' import { rotateBilinear } from './util/rotate.ts'; import { gaussianBlur, boxBlurRGBA, premultiply, unpremultiply } from './util/blur.ts'; import { Colors, wrapHue, hue2rgb } from './colors.ts'; -import { isOptions, finite, color as checkColor, quality as checkQuality, effort as checkEffort, compressionLevel } from './util/options.ts'; +import { isOptions, checkOptions, finite, color as checkColor, quality as checkQuality, effort as checkEffort, compressionLevel } from './util/options.ts'; import { MAX_PIXELS, decoding } from './util/limits.ts'; import { GainMap } from './gainmap.ts'; import * as gain from './util/gain.ts'; @@ -1390,6 +1390,7 @@ export class Image { * @param options See {@link CropCircleOptions} */ cropCircle(options: CropCircleOptions = {}): this { + checkOptions(options, 'cropCircle({ size: \'cover\' })'); const { size = 'contain', feathering = 0 } = options; finite(feathering, 'feathering'); if (size !== 'contain' && size !== 'cover') throw new TypeError(`Invalid circle size: ${String(size)}`); @@ -3031,6 +3032,8 @@ export class Image { * @return The rendered text */ static async renderText(text: string, options: RenderTextOptions): Promise { + if (typeof text !== 'string' || !isOptions(options)) throw new TypeError('renderText() takes the text and an options object, like renderText(\'Hello\', { font, size: 32 })'); + if (options.font === undefined) throw new TypeError('renderText(text, options) needs the font in the options'); const { font, color = 0xffffffff, strokeWidth = 0, strokeColor = 0x000000ff, hinting = true, features = {}, variations = {} } = options; const layout = options.layout instanceof TextLayout ? options.layout : new TextLayout(options.layout); const axes = textRenderer.axisValues(variations); diff --git a/src/util/options.ts b/src/util/options.ts index 3591ea27..70dba416 100644 --- a/src/util/options.ts +++ b/src/util/options.ts @@ -3,6 +3,15 @@ export function isOptions(value: T): value is Extract { return typeof value === 'object' && value !== null && !ArrayBuffer.isView(value); } +/** + * Checks that the options argument of a method is left out or an object. A method that only takes options (4.0 removed + * the positional forms) throws a TypeError for anything else instead of silently ignoring it, which names the form that + * works: `usage` is e.g. `cropCircle({ size: 'cover' })` + */ +export function checkOptions(value: unknown, usage: string): void { + if (value !== undefined && !isOptions(value)) throw new TypeError(`${usage.split('(')[0]}() takes an options object, like ${usage}, not ${value === null ? 'null' : typeof value}`); +} + /** * A number argument, converted like arithmetic does (numeric strings keep working); NaN and the infinities throw * a RangeError naming the argument diff --git a/tests/options-objects.js b/tests/options-objects.js new file mode 100644 index 00000000..e374a362 --- /dev/null +++ b/tests/options-objects.js @@ -0,0 +1,44 @@ +const fs = require('fs').promises; +const {Image} = require('..'); + +const panic = message => { + console.error(message); + process.exit(1); +}; +const throwsA = async (fn, type, label) => { + let error = null; + try { await fn(); } catch (e) { error = e; } + if (!(error instanceof type)) panic(`${label}: should throw a ${type.name}, got ${error}`); + return error; +}; + +(async () => { + const font = await fs.readFile('./tests/fonts/opensans bold.ttf'); + const photo = new Image(20, 12).fill((x, y) => 0x336699ff + x * 0x01000000 + y * 0x00010000 >>> 0); + + // methods that only take options refuse anything else (the positional forms of 3.x are gone), instead of ignoring it + for (const [label, bad] of Object.entries({'true': true, 'false': false, 'a number': 0.5, 'a string': 'cover', 'null': null, 'a typed array': new Uint8Array(2)})) { + const image = photo.clone(); + const error = await throwsA(() => image.cropCircle(bad), TypeError, `cropCircle(${label})`); + if (!/cropCircle\(\) takes an options object/.test(error.message)) panic(`cropCircle(${label}): the message should name the options form, got ${error.message}`); + if (!Buffer.from(image.bitmap).equals(Buffer.from(photo.bitmap))) panic(`cropCircle(${label}): the image should be unchanged`); + } + for (const fine of [undefined, {}, {size: 'cover'}, {size: 'contain', feathering: 0.1}]) photo.clone().cropCircle(fine); + await throwsA(() => photo.clone().cropCircle({size: 'huge'}), TypeError, 'cropCircle with an unknown size'); + + // renderText(text, { font, size, ... }) is the only form: the 3.x forms with the font first fail clearly + const ok = await Image.renderText('Hi', {font, size: 24}); + if (ok.width < 4 || ok.height < 4) panic(`renderText: ${ok.width}x${ok.height}`); + for (const [label, call] of Object.entries({ + 'the font first': () => Image.renderText(font, 32, 'Hi'), + 'no options': () => Image.renderText('Hi'), + 'null options': () => Image.renderText('Hi', null), + 'a size instead of options': () => Image.renderText('Hi', 32), + 'a number as the text': () => Image.renderText(5, {font, size: 10}) + })) { + const error = await throwsA(call, TypeError, `renderText with ${label}`); + if (!/renderText\(/.test(error.message)) panic(`renderText with ${label}: the message should show the form, got ${error.message}`); + } + const noFont = await throwsA(() => Image.renderText('Hi', {size: 10}), TypeError, 'renderText without a font'); + if (!/font/.test(noFont.message)) panic(`renderText without a font: ${noFont.message}`); +})().catch(error => panic(error.stack || String(error))); From 6556b04cbcefb1e2636466890ecf58a8a1b96356 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 16:43:04 +0000 Subject: [PATCH 011/188] remove the positional mode of the resize functions --- CHANGELOG.md | 2 + src/animation.ts | 19 +++------ src/frame.ts | 16 ++----- src/image.ts | 97 ++++++++++++------------------------------- tests/misc.js | 2 +- tests/options-only.js | 54 ++++++++++++++++++++++++ tests/options.js | 6 --- tests/regressions.js | 8 ++-- 8 files changed, 97 insertions(+), 107 deletions(-) create mode 100644 tests/options-only.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e0caa61..b54fe33a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ - **The deprecated animation encoder option types and `AnimationEncoder.frames` are removed**: `AnimationEncoderOptions`, `WebPAnimationEncoderOptions`, `GIFAnimationEncoderOptions` and `APNGAnimationEncoderOptions` are `AnimationEncodeOptions`, `WebPAnimationEncodeOptions`, `GIFEncodeOptions` and `APNGEncodeOptions`, each with `AnimationOptions` for the `loopCount`, and `encoder.frames` is `encoder.frameCount`. +- **The positional mode of `scale()`, `resize()`, `contain()`, `fit()` and `cover()` is removed**, on `Image`, `Frame` and `Animation`: `image.resize(width, height, 'area')` and `image.resize(width, height, ResizeModes.AREA)` are now `image.resize(width, height, { mode: 'area' })`, and `image.scale(0.5, { mode })` takes the mode in the same way. Other values in the position throw a `TypeError`, and `fast` goes in the same object. + ## 3.20.1 ### Performance diff --git a/src/animation.ts b/src/animation.ts index 1fe680c8..8a8796d4 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -7,7 +7,7 @@ import { availableThreads } from './util/threads.ts'; import { BinaryData, view } from './util/mem.ts'; import { GainMap } from './gainmap.ts'; import type { ToneMapping } from './codecs/hdr.ts'; -import { Image, ResizeMode, ResizeOptions, ResizeToOptions, resizeArguments } from './image.ts'; +import { Image, ResizeOptions, ResizeToOptions, resizeArguments } from './image.ts'; import { Frame, FrameDisposalModes } from './frame.ts'; import { ImageType } from './image-type.ts'; import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts'; @@ -232,23 +232,14 @@ export class Animation extends Array { * @param options See {@link ResizeOptions} */ resize(width: number, height: number, options?: ResizeOptions): this; - /** - * Resizes the animation to the given dimensions, scaling every frame and its offsets proportionally. - * Pass {@link ResizeToOptions} with only a width or a height to keep the aspect ratio. - * @param width The new width - * @param height The new height - * @param mode The resizing mode to use. Default: {@link ResizeModes.BILINEAR} - * @deprecated Pass {@link ResizeOptions} with `mode` instead, the positional mode will be removed in 4.0 - */ - resize(width: number, height: number, mode?: ResizeMode): this; /** * Resizes the animation, scaling every frame and its offsets proportionally: to the given width or height (the * other side keeps the aspect ratio), or to both * @param options The size and how to resize, see {@link ResizeToOptions} */ resize(options: ResizeToOptions): this; - resize(widthOrOptions: number | ResizeToOptions, heightArgument?: number, modeArgument?: ResizeMode | ResizeOptions): this { - const [givenWidth, givenHeight, modeOrOptions] = resizeArguments(widthOrOptions, heightArgument, modeArgument); + resize(widthOrOptions: number | ResizeToOptions, heightArgument?: number, optionsArgument?: ResizeOptions): this { + const [givenWidth, givenHeight, options] = resizeArguments(widthOrOptions, heightArgument, optionsArgument); const originalWidth = this.width, originalHeight = this.height; // a side left out follows the other one, keeping the aspect ratio const width = givenWidth ?? originalWidth / originalHeight * (givenHeight as number); @@ -259,8 +250,8 @@ export class Animation extends Array { // scale both edges so adjacent frames stay aligned const left = Math.round(frame.xOffset * sx), top = Math.round(frame.yOffset * sy); const right = Math.round((frame.xOffset + frame.width) * sx), bottom = Math.round((frame.yOffset + frame.height) * sy); - (Image.prototype.resize as (width: number, height: number, mode?: ResizeMode | ResizeOptions) => Image) - .call(frame, Math.max(1, right - left), Math.max(1, bottom - top), modeOrOptions); + (Image.prototype.resize as (width: number, height: number, options?: ResizeOptions) => Image) + .call(frame, Math.max(1, right - left), Math.max(1, bottom - top), options); frame.xOffset = left; frame.yOffset = top; } diff --git a/src/frame.ts b/src/frame.ts index baa301f9..938094f1 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -1,4 +1,4 @@ -import { Image, ResizeMode, ResizeOptions, ResizeToOptions, Color, TrimOptions, resizeArguments } from './image.ts'; +import { Image, ResizeOptions, ResizeToOptions, Color, TrimOptions, resizeArguments } from './image.ts'; import { isOptions, finite } from './util/options.ts'; /** Names of the frame disposal modes, see {@link Frame.disposalMode} */ @@ -210,26 +210,18 @@ export class Frame extends Image { * @param options See {@link ResizeOptions} */ override resize(width: number, height: number, options?: ResizeOptions): this; - /** - * Resizes the frame, scaling its offsets along with it. - * @param width The new width - * @param height The new height - * @param mode The resizing mode (default: {@link ResizeModes.BILINEAR}) - * @deprecated Pass {@link ResizeOptions} with `mode` instead, the positional mode will be removed in 4.0 - */ - override resize(width: number, height: number, mode?: ResizeMode): this; /** * Resizes the frame, scaling its offsets along with it: to the given width or height (the other side keeps the * aspect ratio), or to both * @param options The size and how to resize, see {@link ResizeToOptions} */ override resize(options: ResizeToOptions): this; - override resize(widthOrOptions: number | ResizeToOptions, heightArgument?: number, modeArgument?: ResizeMode | ResizeOptions): this { - const [width, height, modeOrOptions] = resizeArguments(widthOrOptions, heightArgument, modeArgument); + override resize(widthOrOptions: number | ResizeToOptions, heightArgument?: number, optionsArgument?: ResizeOptions): this { + const [width, height, options] = resizeArguments(widthOrOptions, heightArgument, optionsArgument); const originalWidth = this.width; const originalHeight = this.height; - super.resize(...this.__resized_size__(width, height), modeOrOptions as ResizeMode); + super.resize(...this.__resized_size__(width, height), options); this.xOffset = Math.round(this.xOffset * this.width / originalWidth); this.yOffset = Math.round(this.yOffset * this.height / originalHeight); diff --git a/src/image.ts b/src/image.ts index f39af07d..7a7defa5 100644 --- a/src/image.ts +++ b/src/image.ts @@ -919,15 +919,9 @@ export class Image { * @param options See {@link ResizeOptions} */ scale(factor: number, options?: ResizeOptions): this; - /** - * Resizes the image by the given factor - * @param factor The factor to resize the image with - * @param [mode=ResizeModes.BILINEAR] The resizing mode to use - * @deprecated Pass {@link ResizeOptions} with `mode` instead, the positional mode will be removed in 4.0 - */ - scale(factor: number, mode?: ResizeMode): this; - scale(factor: number, modeOrOptions?: ResizeMode | ResizeOptions): this { - const { mode, fast } = resizeOptions(modeOrOptions); + scale(factor: number, options?: ResizeOptions): this { + checkOptions(options, 'scale(factor, { mode: \'area\' })'); + const { mode, fast } = resizeOptions(options); const image = this.__scale__(factor, mode, fast); this.__gain_map__?.__rescale__(this.width, this.height, image.width, image.height); return this.__apply__(image); @@ -952,25 +946,15 @@ export class Image { * @returns The resized image */ resize(width: number, height: number, options?: ResizeOptions): this; - /** - * Resizes the image to the given dimensions. - * Pass {@link ResizeToOptions} with only a width or a height to keep the aspect ratio. - * @param width The new width - * @param height The new height - * @param [mode=ResizeModes.BILINEAR] The resizing mode to use - * @returns The resized image - * @deprecated Pass {@link ResizeOptions} with `mode` instead, the positional mode will be removed in 4.0 - */ - resize(width: number, height: number, mode?: ResizeMode): this; /** * Resizes the image: to the given width or height (the other side keeps the aspect ratio), or to both * @param options The size and how to resize, see {@link ResizeToOptions} * @returns The resized image */ resize(options: ResizeToOptions): this; - resize(widthOrOptions: number | ResizeToOptions, heightArgument?: number, modeArgument?: ResizeMode | ResizeOptions): this { - const [width, height, modeOrOptions] = resizeArguments(widthOrOptions, heightArgument, modeArgument); - const { mode, fast } = resizeOptions(modeOrOptions); + resize(widthOrOptions: number | ResizeToOptions, heightArgument?: number, optionsArgument?: ResizeOptions): this { + const [width, height, options] = resizeArguments(widthOrOptions, heightArgument, optionsArgument); + const { mode, fast } = resizeOptions(options); const image = this.__resize__(...this.__resized_size__(width, height), mode, fast); this.__gain_map__?.__rescale__(this.width, this.height, image.width, image.height); return this.__apply__(image); @@ -985,23 +969,14 @@ export class Image { * @returns The resized image */ contain(width: number, height: number, options?: ResizeOptions): this; - /** - * Resizes the image so it is contained in the given bounding box. - * Can return an image with one axis smaller than the given bounding box. - * @param width The width of the bounding box - * @param height The height of the bounding box - * @param [mode=ResizeModes.BILINEAR] The resizing mode to use - * @returns The resized image - * @deprecated Pass {@link ResizeOptions} with `mode` instead, the positional mode will be removed in 4.0 - */ - contain(width: number, height: number, mode?: ResizeMode): this; - contain(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this { - const options = resizeOptions(modeOrOptions); + contain(width: number, height: number, options?: ResizeOptions): this { + checkOptions(options, 'contain(width, height, { mode: \'area\' })'); + const resizeOpts = resizeOptions(options); width = Math.floor(finite(width, 'width')); height = Math.floor(finite(height, 'height')); // round instead of flooring the scaled size, so the limiting axis matches the box exactly const scale = Math.min(width / this.width, height / this.height); - return this.resize(Math.max(1, Math.min(width, Math.round(this.width * scale))), Math.max(1, Math.min(height, Math.round(this.height * scale))), options); + return this.resize(Math.max(1, Math.min(width, Math.round(this.width * scale))), Math.max(1, Math.min(height, Math.round(this.height * scale))), resizeOpts); } /** @@ -1013,18 +988,8 @@ export class Image { * @returns The resized image */ fit(width: number, height: number, options?: ResizeOptions): this; - /** - * Resizes the image so it is contained in the given bounding box, placing it in the center of the given bounding box. - * Always returns the exact dimensions of the bounding box. - * @param width The width of the bounding box - * @param height The height of the bounding box - * @param [mode=ResizeModes.BILINEAR] The resizing mode to use - * @returns The resized image - * @deprecated Pass {@link ResizeOptions} with `mode` instead, the positional mode will be removed in 4.0 - */ - fit(width: number, height: number, mode?: ResizeMode): this; - fit(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this { - const options = resizeOptions(modeOrOptions); + fit(width: number, height: number, options?: ResizeOptions): this { + checkOptions(options, 'fit(width, height, { mode: \'area\' })'); const result = new Image(width, height); this.contain(width, height, options); const x = (width - this.width) / 2 | 0, y = (height - this.height) / 2 | 0; @@ -1042,18 +1007,9 @@ export class Image { * @returns The resized image */ cover(width: number, height: number, options?: ResizeOptions): this; - /** - * Resizes the image so it covers the given bounding box, cropping the overflowing edges. - * Always returns the exact dimensions of the bounding box. - * @param width The width of the bounding box - * @param height The height of the bounding box - * @param [mode=ResizeModes.BILINEAR] The resizing mode to use - * @returns The resized image - * @deprecated Pass {@link ResizeOptions} with `mode` instead, the positional mode will be removed in 4.0 - */ - cover(width: number, height: number, mode?: ResizeMode): this; - cover(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this { - const options = resizeOptions(modeOrOptions); + cover(width: number, height: number, options?: ResizeOptions): this { + checkOptions(options, 'cover(width, height, { mode: \'area\' })'); + const resizeOpts = resizeOptions(options); width = Math.floor(width); height = Math.floor(height); // (checked before anything changes: a box below a pixel used to resize the image, then fail to crop it) @@ -1064,14 +1020,14 @@ export class Image { const scale = Math.max(width / this.width, height / this.height); const resizedWidth = Math.max(width, Math.round(this.width * scale)), resizedHeight = Math.max(height, Math.round(this.height * scale)); // only the part the crop keeps is resampled (the same pixels as resizing the whole image, then cropping) - if (options.mode !== ResizeModes.NEAREST_NEIGHBOR && width >= 1 && height >= 1) { + if (resizeOpts.mode !== ResizeModes.NEAREST_NEIGHBOR && width >= 1 && height >= 1) { const region = { x: ~~((resizedWidth - width) / 2), y: ~~((resizedHeight - height) / 2), width, height }; this.__rescaled__?.(resizedWidth, resizedHeight); const fx = resizedWidth / this.width, fy = resizedHeight / this.height; this.__gain_map__?.__transform__(this.width, this.height, width, height, (X, Y) => [(X + region.x) / fx, (Y + region.y) / fy], Math.min(fx, fy)); - return this.__apply__(this.__resize__(resizedWidth, resizedHeight, options.mode, options.fast, region)); + return this.__apply__(this.__resize__(resizedWidth, resizedHeight, resizeOpts.mode, resizeOpts.fast, region)); } - this.resize(resizedWidth, resizedHeight, options); + this.resize(resizedWidth, resizedHeight, resizeOpts); return this.crop((this.width - width) / 2, (this.height - height) / 2, width, height); } @@ -3146,17 +3102,18 @@ interface HSLA { * The width, height and mode of resize()'s signatures: a side left out of the options is `undefined` * @internal */ -export function resizeArguments(widthOrOptions: number | ResizeToOptions, height?: number, modeOrOptions?: ResizeMode | ResizeOptions): [width: number | undefined, height: number | undefined, modeOrOptions: ResizeMode | ResizeOptions | undefined] { - if (!isOptions(widthOrOptions)) return [widthOrOptions, height, modeOrOptions]; - const { width, height: optionsHeight, ...options } = widthOrOptions; +export function resizeArguments(widthOrOptions: number | ResizeToOptions, height?: number, options?: ResizeOptions): [width: number | undefined, height: number | undefined, options: ResizeOptions | undefined] { + if (!isOptions(widthOrOptions)) { + checkOptions(options, 'resize(width, height, { mode: \'area\' })'); + return [widthOrOptions, height, options]; + } + const { width, height: optionsHeight, ...rest } = widthOrOptions; if (width === undefined && optionsHeight === undefined) throw new TypeError('resize() needs a width, a height or both'); - return [width, optionsHeight, options]; + return [width, optionsHeight, rest]; } -function resizeOptions(modeOrOptions?: ResizeMode | ResizeOptions): { mode: ResizeMode, fast: boolean } { - const options = isOptions(modeOrOptions) ? modeOrOptions : { mode: modeOrOptions }; - const mode = options.mode ?? ResizeModes.BILINEAR; - return { mode, fast: options.fast ?? true }; +function resizeOptions(options?: ResizeOptions): { mode: ResizeMode, fast: boolean } { + return { mode: options?.mode ?? ResizeModes.BILINEAR, fast: options?.fast ?? true }; } // separable blend functions of normalized channel values (W3C Compositing and Blending Level 1) diff --git a/tests/misc.js b/tests/misc.js index 3849a2c9..973ab0f8 100644 --- a/tests/misc.js +++ b/tests/misc.js @@ -128,7 +128,7 @@ if (Colors.fromHSLA(0, 0, 1, 0xff) !== 0xffffffff) } catch { } try { - image.resize(1, 1, 'garbage'); + image.resize(1, 1, {mode: 'garbage'}); panic('resize with invalid mode failed'); } catch { } diff --git a/tests/options-only.js b/tests/options-only.js new file mode 100644 index 00000000..c629ea00 --- /dev/null +++ b/tests/options-only.js @@ -0,0 +1,54 @@ +const {Image, Frame, Animation, ResizeModes} = require('..'); + +const panic = message => { + console.error(message); + process.exit(1); +}; + +// the 3.x positional forms of 4.0 methods throw a TypeError (and leave the image as it was) +const rejects = async (call, label) => { + let error = null; + try { await call(); } catch (e) { error = e; } + if (!(error instanceof TypeError)) panic(`${label}: should throw a TypeError, got ${error}`); + return error; +}; + +const sameImage = (image, reference, label) => { + if (image.width !== reference.width || image.height !== reference.height || !Buffer.from(image.bitmap).equals(Buffer.from(reference.bitmap))) + panic(`${label}: the image should be unchanged`); +}; + +(async () => { + const photo = new Image(20, 12).fill((x, y) => 0x336699ff + x * 0x01000000 + y * 0x00010000 >>> 0); + + // resize, scale, contain, fit and cover take the resizing mode in the options only + const modes = {'a mode name': ResizeModes.AREA, 'a string': 'bilinear', 'an unknown mode': 'Area', 'null': null}; + for (const [label, mode] of Object.entries(modes)) { + for (const method of ['resize', 'contain', 'fit', 'cover']) { + const image = photo.clone(); + const error = await rejects(() => image[method](10, 6, mode), `${method}(10, 6, ${label})`); + if (!/takes an options object/.test(error.message)) panic(`${method}(10, 6, ${label}): the message should name the options form, got ${error.message}`); + sameImage(image, photo, `${method}(10, 6, ${label})`); + } + const scaled = photo.clone(); + await rejects(() => scaled.scale(0.5, mode), `scale(0.5, ${label})`); + sameImage(scaled, photo, `scale(0.5, ${label})`); + const frame = new Frame(20, 12, {duration: 50}); + await rejects(() => frame.resize(10, 6, mode), `Frame.resize(10, 6, ${label})`); + if (frame.width !== 20 || frame.height !== 12) panic(`Frame.resize(10, 6, ${label}): the frame should keep its size`); + const animation = new Animation([new Frame(20, 12)]); + await rejects(() => animation.resize(10, 6, mode), `Animation.resize(10, 6, ${label})`); + if (animation[0].width !== 20 || animation[0].height !== 12) panic(`Animation.resize(10, 6, ${label}): the frames should keep their size`); + } + + // the options form still resizes; undefined stays fine + if (photo.clone().resize(10, 6, {mode: ResizeModes.AREA}).width !== 10) panic('resize(10, 6, { mode })'); + if (photo.clone().resize(10, 6).height !== 6) panic('resize(10, 6)'); + if (photo.clone().resize(10, 6, undefined).height !== 6) panic('resize(10, 6, undefined)'); + if (photo.clone().scale(0.5, {mode: ResizeModes.AREA}).width !== 10) panic('scale(0.5, { mode })'); + if (photo.clone().contain(10, 10, {mode: ResizeModes.AREA}).width !== 10) panic('contain(10, 10, { mode })'); + if (photo.clone().fit(10, 10, {mode: ResizeModes.AREA}).height !== 10) panic('fit(10, 10, { mode })'); + if (photo.clone().cover(10, 10, {mode: ResizeModes.AREA}).width !== 10) panic('cover(10, 10, { mode })'); + if (new Frame(20, 12).resize(10, 6, {mode: ResizeModes.AREA}).width !== 10) panic('Frame.resize(10, 6, { mode })'); + if (new Animation([new Frame(20, 12)]).resize(10, 6, {mode: ResizeModes.AREA})[0].width !== 10) panic('Animation.resize(10, 6, { mode })'); +})().catch(error => panic(error.stack || String(error))); diff --git a/tests/options.js b/tests/options.js index 62808d4d..a8cf80da 100644 --- a/tests/options.js +++ b/tests/options.js @@ -17,9 +17,6 @@ const same = (name, a, b) => { const tile = () => photo.clone().resize(64, 48); const logo = new Image(16, 16).fill(0xff000080); - same('scale', tile().scale(0.5, ResizeModes.NEAREST_NEIGHBOR), tile().scale(0.5, {mode: ResizeModes.NEAREST_NEIGHBOR})); - for (const method of ['resize', 'contain', 'fit', 'cover']) - same(method, tile()[method](30, 40, ResizeModes.BICUBIC), tile()[method](30, 40, {mode: ResizeModes.BICUBIC})); // the default mode same('resize default', tile().resize(30, 40), tile().resize(30, 40, {})); @@ -68,9 +65,6 @@ const same = (name, a, b) => { for (const method of ['encodeGIF', 'encodeAPNG', 'encodeWebP']) if (!Buffer.from(await animation[method]({metadata: {author, comment: author}})).includes(author)) panic(`${method}: metadata missing`); - same('Animation.resize', (await Animation.decode(await animation.encodeAPNG())).resize(20, 10, ResizeModes.NEAREST_NEIGHBOR)[1], - (await Animation.decode(await animation.encodeAPNG())).resize(20, 10, {mode: ResizeModes.NEAREST_NEIGHBOR})[1]); - const gif = await animation.encodeGIF(); for (const decoded of [await Animation.decode(gif, {onlyFirstFrame: true}), await GIF.decode(gif, {onlyFirstFrame: true}), await decode(gif, {onlyFirstFrame: true})]) if (decoded.length !== 1) panic('decode: onlyFirstFrame not applied'); diff --git a/tests/regressions.js b/tests/regressions.js index 8582c5f1..36574706 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -242,7 +242,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); { const checker = new Image(64, 64).fill((x, y) => (x + y) & 1 ? 0xffffffff : 0x000000ff); for (const mode of [ResizeModes.BILINEAR, ResizeModes.BICUBIC, ResizeModes.AREA]) { - const small = checker.clone().resize(8, 8, mode); + const small = checker.clone().resize(8, 8, {mode}); for (const [x, y, color] of small.pixels()) assert(Math.abs((color >>> 24) - 128) <= 2, `${mode}: checkerboard should average to grey at ${x},${y}, got ${color >>> 24}`); } @@ -251,13 +251,13 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const half = new Image(4, 1).fill((x) => x < 2 ? 0xff000000 : 0x0000ffff).resize(2, 1); assert(rgba(half, 0, 0).slice(0, 3).join() === '0,0,255', 'resize: transparent pixels bled into the result: ' + rgba(half, 0, 0)); - const nearest = new Image(4, 1).fill((x) => Colors.fromRGB(x, 0, 0)).resize(2, 1, ResizeModes.NEAREST_NEIGHBOR); + const nearest = new Image(4, 1).fill((x) => Colors.fromRGB(x, 0, 0)).resize(2, 1, {mode: ResizeModes.NEAREST_NEIGHBOR}); assert(rgba(nearest, 0, 0)[0] === 1 && rgba(nearest, 1, 0)[0] === 3, 'nearest neighbor should sample pixel centers'); // (the upper case names of the removed Image.RESIZE_* constants are unknown modes too) for (const unknown of ['RESIZE_FOO', 'RESIZE_BILINEAR', 'RESIZE_AREA']) { let threw = false; - try { checker.clone().resize(8, 8, unknown); } catch { threw = true; } + try { checker.clone().resize(8, 8, {mode: unknown}); } catch { threw = true; } assert(threw, `resize should reject unknown mode ${unknown}`); } } @@ -390,7 +390,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); assert(new Image(8, 4).resize({width: 4, mode: ResizeModes.AREA}).height === 2, 'resize({width}) with a mode keeps the aspect ratio'); const photo = new Image(9, 7).fill((x, y) => Colors.fromRGBA(x * 28, y * 36, (x ^ y) * 16, 255 - x * 10)); let threw = false; - try { photo.clone().resize(4, 4, 'Area'); } catch { threw = true; } + try { photo.clone().resize(4, 4, {mode: 'Area'}); } catch { threw = true; } assert(threw, 'resize modes are case sensitive'); } From 1ad99fd62eb6ce53072d97065ffab277ef41fb52 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 16:47:12 +0000 Subject: [PATCH 012/188] remove the positional arguments of composite and the other image operations --- CHANGELOG.md | 2 + README.md | 2 +- src/frame.ts | 18 ++---- src/image.ts | 117 +++++++++--------------------------- tests/animation-optimize.js | 2 +- tests/animation.js | 2 +- tests/effects.js | 10 +-- tests/gain-redraw.js | 2 +- tests/gainmap.js | 4 +- tests/jxl-encode.js | 2 +- tests/misc.js | 6 +- tests/options-only.js | 37 ++++++++++++ tests/options.js | 4 -- tests/readme.js | 24 ++++---- tests/regressions.js | 55 ++++++++--------- tests/rotate.js | 2 +- tests/trim.js | 8 +-- 17 files changed, 131 insertions(+), 166 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b54fe33a..7fae98e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ - **The positional mode of `scale()`, `resize()`, `contain()`, `fit()` and `cover()` is removed**, on `Image`, `Frame` and `Animation`: `image.resize(width, height, 'area')` and `image.resize(width, height, ResizeModes.AREA)` are now `image.resize(width, height, { mode: 'area' })`, and `image.scale(0.5, { mode })` takes the mode in the same way. Other values in the position throw a `TypeError`, and `fast` goes in the same object. +- **The positional arguments of `composite()`, `rotate()`, `sharpen()`, `dominantColor()`, `roundCorners()`, `fisheye()`, `trim()` and `colorToAlpha()` are removed**: the values go in the options object, which is the only second argument now. `image.composite(logo, 10, 20, { opacity: 0.5 })` is `image.composite(logo, { x: 10, y: 20, opacity: 0.5 })`, `image.sharpen(2, 3)` is `image.sharpen({ amount: 2, radius: 3 })`, `image.rotate(45, false)` is `image.rotate(45, { resize: false })`, `image.dominantColor(true, true, 15)` is `image.dominantColor({ ignoreBlack: true, ignoreWhite: true, threshold: 15 })`, `image.roundCorners(8)` is `image.roundCorners({ radius: 8 })` and `image.fisheye(3)` is `image.fisheye({ strength: 3 })`. `trim(color, options)` and `colorToAlpha(color, options)` take the color as `color` in the options (`trim({ color, tolerance })`, `colorToAlpha({ color, transparencyThreshold })`), and `Frame.trim()` works the same way. A value in the old position throws a `TypeError`. + ## 3.20.1 ### Performance diff --git a/README.md b/README.md index 15768387..5c06a488 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ import { Image } from 'imagescript'; import { readFile, writeFile } from 'node:fs/promises'; const image = await Image.decode(await readFile('input.jpg')); -image.resize({ width: 256 }).roundCorners(16); +image.resize({ width: 256 }).roundCorners({ radius: 16 }); await writeFile('output.png', await image.encodePNG()); ``` diff --git a/src/frame.ts b/src/frame.ts index 938094f1..d2288a11 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -1,5 +1,5 @@ -import { Image, ResizeOptions, ResizeToOptions, Color, TrimOptions, resizeArguments } from './image.ts'; -import { isOptions, finite } from './util/options.ts'; +import { Image, ResizeOptions, ResizeToOptions, TrimOptions, resizeArguments } from './image.ts'; +import { isOptions, checkOptions, finite } from './util/options.ts'; /** Names of the frame disposal modes, see {@link Frame.disposalMode} */ export type FrameDisposalModeName = 'any' | 'keep' | 'previous' | 'background'; @@ -186,16 +186,10 @@ export class Frame extends Image { * @param options See {@link TrimOptions} */ override trim(options?: TrimOptions): this; - /** - * Removes borders of one color, moving the frame's offsets so the remaining pixels stay in place - * @param color The border color, by default the color of the top left pixel - * @param options See {@link TrimOptions} - * @deprecated Pass {@link TrimOptions} instead, the positional arguments will be removed in 4.0 - */ - override trim(color?: Color, options?: TrimOptions): this; - override trim(colorOrOptions?: Color | TrimOptions, optionsArgument: TrimOptions = {}): this { - const { color, ...options } = isOptions(colorOrOptions) ? colorOrOptions : { ...optionsArgument, color: colorOrOptions ?? optionsArgument.color }; - const bounds = this.__trim_bounds__(color, options); + override trim(options: TrimOptions = {}): this { + checkOptions(options, 'trim({ tolerance: 0.1 })'); + const { color, ...rest } = options; + const bounds = this.__trim_bounds__(color, rest); if (!bounds) return this; this.xOffset += bounds[0]; this.yOffset += bounds[1]; diff --git a/src/image.ts b/src/image.ts index 7a7defa5..50ccdaa2 100644 --- a/src/image.ts +++ b/src/image.ts @@ -101,9 +101,9 @@ export type BlendMode = 'normal' | 'multiply' | 'screen' | 'overlay' | 'darken' /** Options of {@link Image.composite} */ export interface CompositeOptions { - /** The x position to place the source at, if the options are the second argument. Default: `0` */ + /** The x position to place the source at. Default: `0` */ x?: number; - /** The y position to place the source at, if the options are the second argument. Default: `0` */ + /** The y position to place the source at. Default: `0` */ y?: number; /** Opacity of the source (0..1). Default: `1` */ opacity?: number; @@ -993,7 +993,7 @@ export class Image { const result = new Image(width, height); this.contain(width, height, options); const x = (width - this.width) / 2 | 0, y = (height - this.height) / 2 | 0; - result.composite(this, x, y); + result.composite(this, { x, y }); this.__gain_map__?.__transform__(this.width, this.height, result.width, result.height, (X, Y) => [X - x, Y - y]); return this.__apply__(result); } @@ -1159,19 +1159,10 @@ export class Image { * @param options See {@link TrimOptions} */ trim(options?: TrimOptions): this; - /** - * Removes borders of one color, e.g. the empty space around rendered text or around a logo on a solid background. - * Frames keep their position: their offsets move by the trimmed amount. An image that only has the color stays - * unchanged - * @param color The border color, by default the color of the top left pixel. Fully transparent pixels count as the - * same color whatever their RGB values - * @param options See {@link TrimOptions} - * @deprecated Pass {@link TrimOptions} instead, the positional arguments will be removed in 4.0 - */ - trim(color?: Color, options?: TrimOptions): this; - trim(colorOrOptions?: Color | TrimOptions, optionsArgument: TrimOptions = {}): this { - const { color, ...options } = isOptions(colorOrOptions) ? colorOrOptions : { ...optionsArgument, color: colorOrOptions ?? optionsArgument.color }; - const bounds = this.__trim_bounds__(color, options); + trim(options: TrimOptions = {}): this { + checkOptions(options, 'trim({ tolerance: 0.1 })'); + const { color, ...rest } = options; + const bounds = this.__trim_bounds__(color, rest); if (!bounds) return this; this.__crop_gain_map__(this, ...bounds); return this.__apply__(this.__crop__(...bounds)); @@ -1512,19 +1503,10 @@ export class Image { * @param options Position, opacity and blend mode, see {@link CompositeOptions} */ composite(source: Image, options?: CompositeOptions): this; - /** - * Composites (overlays) the source onto this image at the specified coordinates - * @param source The image to place - * @param [x=0] The x position to place the image at - * @param [y=0] The y position to place the image at - * @param options Opacity and blend mode, see {@link CompositeOptions} - * @deprecated Pass {@link CompositeOptions} instead, the positional arguments will be removed in 4.0 - */ - composite(source: Image, x?: number, y?: number, options?: CompositeOptions): this; - composite(source: Image, xOrOptions: number | CompositeOptions = 0, yArgument = 0, optionsArgument: CompositeOptions = {}): this { - const options = isOptions(xOrOptions) ? xOrOptions : optionsArgument; + composite(source: Image, options: CompositeOptions = {}): this { + checkOptions(options, 'composite(source, { x: 10, y: 20 })'); const { opacity = 1, mode = 'normal' } = options; - let x = isOptions(xOrOptions) ? xOrOptions.x ?? 0 : xOrOptions, y = isOptions(xOrOptions) ? xOrOptions.y ?? 0 : yArgument; + let x = options.x ?? 0, y = options.y ?? 0; x = finite(x, 'x') | 0; y = finite(y, 'y') | 0; @@ -1585,7 +1567,7 @@ export class Image { const ownMap = this.__gain_map__, sourceMap = source.__gain_map__; this.__gain_map__ = source.__gain_map__ = null; try { - this.composite(source, x, y, { opacity, mode }); + this.composite(source, { x, y, opacity, mode }); } finally { this.__gain_map__ = ownMap; source.__gain_map__ = sourceMap; @@ -2118,15 +2100,9 @@ export class Image { * @param options See {@link SharpenOptions} */ sharpen(options?: SharpenOptions): this; - /** - * Sharpens the image with an unsharp mask: the difference to a blurred copy is amplified - * @param [amount=1] How strongly to sharpen (0 does nothing) - * @param [radius=1] The radius of the blur the image is compared with (larger values sharpen coarser details) - * @deprecated Pass {@link SharpenOptions} instead, the positional arguments will be removed in 4.0 - */ - sharpen(amount?: number, radius?: number): this; - sharpen(amountOrOptions: number | SharpenOptions = 1, radiusArgument = 1): this { - const { amount = 1, radius = 1 } = isOptions(amountOrOptions) ? amountOrOptions : { amount: amountOrOptions, radius: radiusArgument }; + sharpen(options: SharpenOptions = {}): this { + checkOptions(options, 'sharpen({ amount: 0.5 })'); + const { amount = 1, radius = 1 } = options; if (!Number.isFinite(amount) || amount < 0) throw new RangeError('Invalid sharpen amount'); if (!Number.isFinite(radius) || radius <= 0) @@ -2221,19 +2197,9 @@ export class Image { * @param options See {@link ColorToAlphaOptions} */ colorToAlpha(options?: ColorToAlphaOptions): this; - /** - * Makes a color transparent, like GIMP's "Color to Alpha": the color is removed from every pixel and replaced by - * transparency, so pixels that are partly the color (e.g. antialiased edges against a background) become - * semi-transparent instead of keeping a fringe of it. - * The color is removed everywhere in the image, not only from a connected background: removing a white - * background also makes white areas inside the subject (e.g. eyes in a logo) transparent - * @param color The color to remove, by default the color of the top left pixel (e.g. a background) - * @param options See {@link ColorToAlphaOptions} - * @deprecated Pass {@link ColorToAlphaOptions} instead, the positional arguments will be removed in 4.0 - */ - colorToAlpha(color?: Color, options?: ColorToAlphaOptions): this; - colorToAlpha(colorOrOptions?: Color | ColorToAlphaOptions, optionsArgument: ColorToAlphaOptions = {}): this { - const { color: colorOption, transparencyThreshold = 0, opacityThreshold = 1 } = isOptions(colorOrOptions) ? colorOrOptions : { ...optionsArgument, color: colorOrOptions ?? optionsArgument.color }; + colorToAlpha(options: ColorToAlphaOptions = {}): this { + checkOptions(options, 'colorToAlpha({ color: 0xffffffff })'); + const { color: colorOption, transparencyThreshold = 0, opacityThreshold = 1 } = options; let color = colorOption; if (!(transparencyThreshold >= 0 && opacityThreshold <= 1 && transparencyThreshold < opacityThreshold)) throw new RangeError('Invalid thresholds, they have to satisfy 0 <= transparencyThreshold < opacityThreshold <= 1'); @@ -2307,18 +2273,9 @@ export class Image { * If every pixel is ignored as black or white, the filter is dropped */ dominantColor(options?: DominantColorOptions): Color; - /** - * Gets the image's dominant color, see {@link Image.dominantColor | the variant with options} - * @param ignoreBlack Whether to ignore dark colors (all channels below the threshold). Default: `true` - * @param ignoreWhite Whether to ignore light colors (all channels above 255 minus the threshold). Default: `true` - * @param bwThreshold The black/white threshold (0-64, in steps of 4 channel values). Default: `15` - * @return The image's dominant color (fully opaque) - * @deprecated Pass {@link DominantColorOptions} instead, the positional arguments will be removed in 4.0 - */ - dominantColor(ignoreBlack?: boolean, ignoreWhite?: boolean, bwThreshold?: number): Color; - dominantColor(ignoreBlackOrOptions: boolean | DominantColorOptions = true, ignoreWhiteArgument = true, thresholdArgument = 0xf): Color { - const { ignoreBlack = true, ignoreWhite = true, threshold: bwThreshold = 0xf } = typeof ignoreBlackOrOptions === 'object' && ignoreBlackOrOptions !== null - ? ignoreBlackOrOptions : { ignoreBlack: ignoreBlackOrOptions, ignoreWhite: ignoreWhiteArgument, threshold: thresholdArgument }; + dominantColor(options: DominantColorOptions = {}): Color { + checkOptions(options, 'dominantColor({ ignoreBlack: false })'); + const { ignoreBlack = true, ignoreWhite = true, threshold: bwThreshold = 0xf } = options; const threshold = Math.min(64, Math.max(0, finite(bwThreshold, 'threshold'))) * 4; const pixels = this.__bytes__(); @@ -2335,15 +2292,9 @@ export class Image { * @param options See {@link RotateOptions} */ rotate(angle: number, options?: RotateOptions): this; - /** - * Rotates the image counterclockwise by the given amount of degrees - * @param angle The angle to rotate the image for (in degrees) - * @param resize Whether to resize the image so it fits all pixels or just ignore outlying pixels. Default: `true` - * @deprecated Pass {@link RotateOptions} instead, the positional arguments will be removed in 4.0 - */ - rotate(angle: number, resize?: boolean): this; - rotate(angle: number, resizeOrOptions: boolean | RotateOptions = true): this { - const { resize = true, fast = true } = typeof resizeOrOptions === 'object' && resizeOrOptions !== null ? resizeOrOptions : { resize: resizeOrOptions }; + rotate(angle: number, options: RotateOptions = {}): this { + checkOptions(options, 'rotate(angle, { resize: false })'); + const { resize = true, fast = true } = options; angle = ((finite(angle, 'angle') % 360) + 360) % 360; if (angle === 0) return this; @@ -2541,14 +2492,9 @@ export class Image { * @param options See {@link RoundCornersOptions} */ roundCorners(options?: RoundCornersOptions): this; - /** - * Rounds the images corners, with antialiased edges - * @param [radius=min(width,height)/4] The radius of the corners - * @deprecated Pass {@link RoundCornersOptions} instead, the positional arguments will be removed in 4.0 - */ - roundCorners(radius?: number): this; - roundCorners(radiusOrOptions?: number | RoundCornersOptions): this { - let radius = (isOptions(radiusOrOptions) ? radiusOrOptions.radius : radiusOrOptions) ?? Math.min(this.width, this.height) / 4; + roundCorners(options: RoundCornersOptions = {}): this { + checkOptions(options, 'roundCorners({ radius: 10 })'); + let radius = options.radius ?? Math.min(this.width, this.height) / 4; radius = finite(radius, 'radius'); radius = Math.max(0, Math.min(radius, this.width / 2, this.height / 2)); const size = Math.ceil(radius); @@ -2573,14 +2519,9 @@ export class Image { * @param options See {@link FisheyeOptions} */ fisheye(options?: FisheyeOptions): this; - /** - * Applies a fisheye (barrel distortion) effect - * @param [strength=2] The distortion strength (exponent of the radial mapping), higher values bulge the center more - * @deprecated Pass {@link FisheyeOptions} instead, the positional arguments will be removed in 4.0 - */ - fisheye(strength?: number): this; - fisheye(strengthOrOptions?: number | FisheyeOptions): this { - const strength = finite((isOptions(strengthOrOptions) ? strengthOrOptions.strength : strengthOrOptions) ?? 2, 'strength'); + fisheye(options: FisheyeOptions = {}): this { + checkOptions(options, 'fisheye({ strength: 3 })'); + const strength = finite(options.strength ?? 2, 'strength'); const r = new Image(this.width, this.height); const w = this.width; diff --git a/tests/animation-optimize.js b/tests/animation-optimize.js index 658b365d..b041fd4b 100644 --- a/tests/animation-optimize.js +++ b/tests/animation-optimize.js @@ -71,7 +71,7 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { (async () => { // a sticker: an antialiased disc moving over a transparent background (pixels become transparent again) await check('moving sticker', new Animation(Array.from({length: 8}, (_, i) => - Frame.from(new Image(48, 32).composite(disc(16, 0xff8800ff), i * 4, 4 + (i & 1)), 60)))); + Frame.from(new Image(48, 32).composite(disc(16, 0xff8800ff), {x: i * 4, y: 4 + (i & 1)}), 60)))); // fading out: pixels get more transparent await check('fade out', new Animation(Array.from({length: 6}, (_, i) => diff --git a/tests/animation.js b/tests/animation.js index 32461a16..9220701c 100644 --- a/tests/animation.js +++ b/tests/animation.js @@ -11,7 +11,7 @@ function expected(animation) { let canvas = new Image(width, height); return Array.from(animation, frame => { const previous = canvas.clone(); - canvas.composite(frame, frame.xOffset, frame.yOffset); + canvas.composite(frame, {x: frame.xOffset, y: frame.yOffset}); const shown = canvas.clone(); if (frame.disposalMode === 'background') canvas.drawBox(frame.xOffset, frame.yOffset, frame.width, frame.height, 0); else if (frame.disposalMode === 'previous') canvas = previous; diff --git a/tests/effects.js b/tests/effects.js index 404ced1a..fd3d8883 100644 --- a/tests/effects.js +++ b/tests/effects.js @@ -9,16 +9,16 @@ const samePixels = require('./util/pixels'); const font = await fs.readFile('./tests/fonts/opensans bold.ttf'); const tiles = [ - tile().blur(3), tile().sharpen(2, 2), tile().greyscale(), tile().sepia(), + tile().blur(3), tile().sharpen({amount: 2, radius: 2}), tile().greyscale(), tile().sepia(), tile().brightness(1.3), tile().contrast(1.6), tile().gamma(2), - tile().composite(gradient, 0, 0, {opacity: 0.4}), + tile().composite(gradient, {opacity: 0.4}), ...['multiply', 'screen', 'overlay', 'darken', 'lighten', 'color-dodge', 'color-burn', 'hard-light', 'soft-light', 'difference', 'exclusion', 'add'] - .map(mode => tile().composite(gradient, 0, 0, {mode})) + .map(mode => tile().composite(gradient, {mode})) ]; const image = new Image(5 * 128, 4 * 128 + 80).fill(0x404040ff); - tiles.forEach((t, i) => image.composite(t, (i % 5) * 128, (i / 5 | 0) * 128)); - image.composite(await Image.renderText('Outlined', { font, size: 56, color: 0xffffffff, strokeWidth: 5 }), 8, 4 * 128 + 4); + tiles.forEach((t, i) => image.composite(t, {x: (i % 5) * 128, y: (i / 5 | 0) * 128})); + image.composite(await Image.renderText('Outlined', { font, size: 56, color: 0xffffffff, strokeWidth: 5 }), {x: 8, y: 4 * 128 + 4}); const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); diff --git a/tests/gain-redraw.js b/tests/gain-redraw.js index bd2ef2fc..92da6a5a 100644 --- a/tests/gain-redraw.js +++ b/tests/gain-redraw.js @@ -55,7 +55,7 @@ const mapBytes = image => Buffer.from(image.gainMap.image.bitmap); source.gainMap = (await Image.decode(file)).gainMap.clone(); source.gainMap.gainMapMax = [max + 2, max + 2, max + 2]; source.gainMap.image.fill(0xffffffff); - image.composite(source, 10, 10); + image.composite(source, {x: 10, y: 10}); if (!(map.gainMapMax[0] > max)) panic(`composite: the range should have grown beyond ${max}, it is ${map.gainMapMax[0]}`); for (let i = 0; i < 20; i++) { const x = i * 7 % map.width, y = i * 11 % map.height; diff --git a/tests/gainmap.js b/tests/gainmap.js index bc4ffa8b..276facd1 100644 --- a/tests/gainmap.js +++ b/tests/gainmap.js @@ -148,7 +148,7 @@ const meanValue = (image, left, top, right, bottom) => { // drawing over it: no boost where something without one went, the rest as it was { const image = single.clone(); - image.composite(new Image(100, 100).fill(0x336699ff), 200, 100); + image.composite(new Image(100, 100).fill(0x336699ff), {x: 200, y: 100}); const neutral = stored(image.gainMap, 0); if (Math.abs(meanValue(image, 204, 104, 296, 196) - neutral) > 1.5) panic(`composite: ${meanValue(image, 204, 104, 296, 196)} under the box, no boost is ${neutral}`); // (the range can grow: compared as gains) @@ -158,7 +158,7 @@ const meanValue = (image, left, top, right, bottom) => { const boxed = single.clone().drawBox(0, 0, 64, 64, 0xffffffff); if (Math.abs(meanValue(boxed, 0, 0, 60, 60) - stored(boxed.gainMap, 0)) > 1.5) panic('drawBox: boost left under the box'); // (an image with a map composited onto one without: the canvas gets one, its own part as the source's) - const canvas = new Image(600, 500).fill(0x000000ff).composite(single, 40, 60); + const canvas = new Image(600, 500).fill(0x000000ff).composite(single, {x: 40, y: 60}); if (!canvas.gainMap || Math.abs(meanValue(canvas, 50, 70, 540, 150) - meanValue(single, 10, 10, 500, 90)) > 6) panic('composite onto a canvas: the map'); } diff --git a/tests/jxl-encode.js b/tests/jxl-encode.js index 43059066..0b4e943a 100644 --- a/tests/jxl-encode.js +++ b/tests/jxl-encode.js @@ -169,7 +169,7 @@ const codestream = jxl => { // added back { const text = await Image.renderText('abba cabbage\nbeach bag cab\nabba cabbage', { font: await fs.readFile('./tests/fonts/opensans bold.ttf'), size: 18, color: 0x202020ff }); - const page = new Image(text.width + 16, text.height + 16).fill(0xf4f4f4ff).composite(text, 8, 8); + const page = new Image(text.width + 16, text.height + 16).fill(0xf4f4f4ff).composite(text, {x: 8, y: 8}); const {findPatches} = await import('../dist/codecs/jxl/patches-enc.js'); const w = page.width, h = page.height, planes = [0, 1, 2].map(c => Float32Array.from({length: w * h}, (_, i) => page.bitmap[i * 4 + c] / 255)); if (!findPatches(planes, w, w, h, 1, 0)) panic('patches: none found in text'); diff --git a/tests/misc.js b/tests/misc.js index 973ab0f8..c60586e1 100644 --- a/tests/misc.js +++ b/tests/misc.js @@ -166,11 +166,11 @@ if (Colors.fromHSLA(0, 0, 1, 0xff) !== 0xffffffff) { const image = new Image(512, 256); const overlay = new Image(1024, 512); - image.composite(overlay, -512, -256); + image.composite(overlay, {x: -512, y: -256}); overlay.fill(0x80); - image.composite(overlay, -512, -256); + image.composite(overlay, {x: -512, y: -256}); overlay.fill(0xff); - image.composite(overlay, -512, -256); + image.composite(overlay, {x: -512, y: -256}); } try { diff --git a/tests/options-only.js b/tests/options-only.js index c629ea00..0d558940 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -41,6 +41,43 @@ const sameImage = (image, reference, label) => { if (animation[0].width !== 20 || animation[0].height !== 12) panic(`Animation.resize(10, 6, ${label}): the frames should keep their size`); } + // the image operations take their values in the options object only + const patch = new Image(2, 2).fill(0xff0000ff); + const oldCalls = { + 'composite(patch, 3, 4)': image => image.composite(patch, 3, 4), + 'composite(patch, 3)': image => image.composite(patch, 3), + 'composite(patch, 3, 4, { opacity: 0.5 })': image => image.composite(patch, 3, 4, {opacity: 0.5}), + 'trim(0xffffffff)': image => image.trim(0xffffffff), + 'trim(0xffffffff, { tolerance: 0.1 })': image => image.trim(0xffffffff, {tolerance: 0.1}), + 'colorToAlpha(0xffffffff)': image => image.colorToAlpha(0xffffffff), + 'colorToAlpha(0xffffffff, { opacityThreshold: 0.5 })': image => image.colorToAlpha(0xffffffff, {opacityThreshold: 0.5}), + 'sharpen(2, 3)': image => image.sharpen(2, 3), + 'sharpen(2)': image => image.sharpen(2), + 'dominantColor(true)': image => image.dominantColor(true), + 'dominantColor(true, false, 4)': image => image.dominantColor(true, false, 4), + 'rotate(45, false)': image => image.rotate(45, false), + 'rotate(45, true)': image => image.rotate(45, true), + 'roundCorners(3)': image => image.roundCorners(3), + 'fisheye(3)': image => image.fisheye(3) + }; + for (const [label, call] of Object.entries(oldCalls)) { + const image = photo.clone(); + const error = await rejects(() => call(image), label); + if (!/takes an options object/.test(error.message)) panic(`${label}: the message should name the options form, got ${error.message}`); + sameImage(image, photo, label); + } + { + const frame = new Frame(20, 12, {xOffset: 4, yOffset: 5}); + await rejects(() => frame.trim(0xffffffff), 'Frame.trim(0xffffffff)'); + if (frame.xOffset !== 4 || frame.yOffset !== 5 || frame.width !== 20) panic('Frame.trim(0xffffffff): the frame should be unchanged'); + } + const solid = new Image(6, 6).fill(0xffffffff).drawBox(2, 2, 2, 2, 0x000000ff); + if (solid.clone().trim({color: 0xffffffff}).width !== 2) panic('trim({ color }) should trim the color'); + if (solid.clone().composite(patch, {x: 1, y: 1}).getPixelAt(1, 1) !== 0xff0000ff) panic('composite(patch, { x, y }) should place the patch'); + if (solid.clone().sharpen({amount: 0}).getPixelAt(2, 2) !== 0x000000ff) panic('sharpen({ amount: 0 }) should not change the pixels'); + if (solid.clone().roundCorners({radius: 0}).getPixelAt(0, 0) !== 0xffffffff) panic('roundCorners({ radius: 0 }) should not change the pixels'); + if (solid.clone().rotate(90, {resize: false}).width !== 6) panic('rotate(90, { resize: false })'); + // the options form still resizes; undefined stays fine if (photo.clone().resize(10, 6, {mode: ResizeModes.AREA}).width !== 10) panic('resize(10, 6, { mode })'); if (photo.clone().resize(10, 6).height !== 6) panic('resize(10, 6)'); diff --git a/tests/options.js b/tests/options.js index a8cf80da..cda98325 100644 --- a/tests/options.js +++ b/tests/options.js @@ -20,12 +20,8 @@ const same = (name, a, b) => { // the default mode same('resize default', tile().resize(30, 40), tile().resize(30, 40, {})); - same('composite', tile().composite(logo, 5, 7, {opacity: 0.5, mode: 'multiply'}), tile().composite(logo, {x: 5, y: 7, opacity: 0.5, mode: 'multiply'})); same('composite default', tile().composite(logo), tile().composite(logo, {})); - same('sharpen', tile().sharpen(2, 3), tile().sharpen({amount: 2, radius: 3})); - same('roundCorners', tile().roundCorners(9), tile().roundCorners({radius: 9})); same('roundCorners default', tile().roundCorners(), tile().roundCorners({})); - same('fisheye', tile().fisheye(3), tile().fisheye({strength: 3})); same('fisheye default', tile().fisheye(), tile().fisheye({})); // encoders: decoded, since the metadata has a creation time diff --git a/tests/readme.js b/tests/readme.js index 1372efce..d82b95d4 100644 --- a/tests/readme.js +++ b/tests/readme.js @@ -30,15 +30,15 @@ const font = name => fs.readFileSync('./tests/fonts/' + name); // a frosted glass card: the background behind it, blurred and lightened, with a soft shadow const card = {x: 40, y: 40, width: 520, height: 520, radius: 28}; const shadow = new Image(card.width + 80, card.height + 80).drawBox(40, 48, card.width, card.height, 0x00000060).blur(16); - image.composite(shadow, card.x - 40, card.y - 40); + image.composite(shadow, {x: card.x - 40, y: card.y - 40}); const glass = image.region(card.x, card.y, card.width, card.height).blur(24) - .composite(new Image(card.width, card.height).fill(0xffffffff), 0, 0, {opacity: 0.18}) - .roundCorners(card.radius); - image.composite(glass, card.x, card.y); + .composite(new Image(card.width, card.height).fill(0xffffffff), {opacity: 0.18}) + .roundCorners({radius: card.radius}); + image.composite(glass, {x: card.x, y: card.y}); const text = async (fonts, size, string, color, x, y) => { const rendered = await Image.renderText(string, { font: fonts, size, color }); - image.composite(rendered, x, y); + image.composite(rendered, {x, y}); return rendered; }; const left = card.x + 40; @@ -53,7 +53,7 @@ const font = name => fs.readFileSync('./tests/fonts/' + name); `); - image.composite(icon, card.x + card.width - 40 - icon.width, card.y + 150); + image.composite(icon, {x: card.x + card.width - 40 - icon.width, y: card.y + 150}); // the week: bars from the lowest to the highest temperature, in a gradient from cool to warm const days = [['Mon', 14, 21], ['Tue', 13, 19], ['Wed', 15, 23], ['Thu', 16, 25], ['Fri', 14, 22], ['Sat', 12, 18], ['Sun', 13, 20]]; @@ -64,20 +64,20 @@ const font = name => fs.readFileSync('./tests/fonts/' + name); const cx = left + step * i + step / 2; const y0 = chart.top + chart.height * (1 - (high - chart.min) / (chart.max - chart.min)); const y1 = chart.top + chart.height * (1 - (low - chart.min) / (chart.max - chart.min)); - const track = new Image(14, chart.height).fill(0xffffff30).roundCorners(7); - image.composite(track, cx - 7, chart.top); + const track = new Image(14, chart.height).fill(0xffffff30).roundCorners({radius: 7}); + image.composite(track, {x: cx - 7, y: chart.top}); const bar = new Image(14, y1 - y0).fill((x, y) => gradient((chart.max - (y0 + y - chart.top) / chart.height * (chart.max - chart.min) - chart.min) / (chart.max - chart.min))); - image.composite(bar.roundCorners(7), cx - 7, y0); + image.composite(bar.roundCorners({radius: 7}), {x: cx - 7, y: y0}); const label = await Image.renderText(day, { font: regular, size: 20, color: 0xffffffc0 }); - image.composite(label, cx - label.width / 2, chart.top + chart.height + 10); + image.composite(label, {x: cx - label.width / 2, y: chart.top + chart.height + 10}); } // a divider, then greetings in a few scripts image.drawBox(left, card.y + 446, card.width - 80, 1, 0xffffff40); const greeting = await Image.renderText('Bom dia · مرحبا · नमस्ते 😀', { font: scripts, size: 26, color: 0xffffffe0 }); - image.composite(greeting, left, card.y + 446 + (card.height - 446 - greeting.height) / 2); + image.composite(greeting, {x: left, y: card.y + 446 + (card.height - 446 - greeting.height) / 2}); - image.roundCorners(24); + image.roundCorners({radius: 24}); const encoded = await image.encodePNG({metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) diff --git a/tests/regressions.js b/tests/regressions.js index 36574706..5f8f6b78 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -32,7 +32,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // free rotation keeps the edges { - const image = new Image(4, 4).fill(0xff0000ff).rotate(0.001, false); + const image = new Image(4, 4).fill(0xff0000ff).rotate(0.001, {resize: false}); for (const [x, y, color] of image.pixels()) assert((color & 0xff) > 250, `rotate: edge pixel ${x},${y} lost`); } @@ -131,10 +131,10 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const box = new Image(3, 3).drawBox(1, 1, 1, 1, 0xffffffff); assert(box.getPixelAt(1, 1) === 0xffffffff && box.getPixelAt(0, 0) === 0, 'drawBox should be 0-based'); - const target = new Image(3, 3).composite(new Image(1, 1).fill(0xffffffff), 1, 1); + const target = new Image(3, 3).composite(new Image(1, 1).fill(0xffffffff), {x: 1, y: 1}); assert(target.getPixelAt(1, 1) === 0xffffffff, 'composite and getPixelAt should agree'); - const corners = new Image(8, 8).fill(0xffffffff).roundCorners(3); + const corners = new Image(8, 8).fill(0xffffffff).roundCorners({radius: 3}); const alpha = (x, y) => corners.getPixelAt(x, y) & 0xff; for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) @@ -557,19 +557,14 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); assert((await Animation.decode(codestream, {toneMapping: 'clamp'})).length === plain.length, 'Animation.decode with toneMapping clamp'); } - // trim, colorToAlpha and GainMap.encode take their values in options too (the positional color and quality are deprecated, removed in 4.0) + // trim and colorToAlpha take their color in the options too { const {GainMap} = require('..'); const bordered = () => new Image(6, 5).fill(0x102030ff).setPixelAt(2, 2, 0xffffffff).setPixelAt(3, 2, 0x00000000); const same = (a, b) => a.width === b.width && a.height === b.height && Buffer.from(a.bitmap).equals(Buffer.from(b.bitmap)); - assert(same(bordered().trim({color: 0x102030ff}), bordered().trim(0x102030ff)), 'trim({color}) vs trim(color)'); - assert(same(bordered().trim({color: 0x102030ff, tolerance: 0.1}), bordered().trim(0x102030ff, {tolerance: 0.1})), 'trim({color, tolerance})'); - assert(same(bordered().trim({tolerance: 0.1}), bordered().trim(undefined, {tolerance: 0.1})), 'trim({tolerance}) keeps the top left color'); assert(same(bordered().trim(), bordered().trim(undefined)) && same(bordered().trim(), bordered().trim({})), 'trim() without arguments'); const frame = () => Frame.from(bordered(), 100, 3, 4); - assert(same(frame().trim({color: 0x102030ff}), frame().trim(0x102030ff)) && frame().trim({color: 0x102030ff}).xOffset === 5, 'Frame.trim({color}) moves the offsets'); - assert(same(bordered().colorToAlpha({color: 0x102030ff}), bordered().colorToAlpha(0x102030ff)), 'colorToAlpha({color}) vs colorToAlpha(color)'); - assert(same(bordered().colorToAlpha({transparencyThreshold: 0.2}), bordered().colorToAlpha(undefined, {transparencyThreshold: 0.2})), 'colorToAlpha({transparencyThreshold})'); + assert(frame().trim({color: 0x102030ff}).xOffset === 5, 'Frame.trim({color}) moves the offsets'); assert(same(bordered().colorToAlpha(), bordered().colorToAlpha(undefined)) && same(bordered().colorToAlpha(), bordered().colorToAlpha({})), 'colorToAlpha() without arguments'); const map = new GainMap(new Image(4, 4).fill(0x808080ff), {gainMapMin: 0, gainMapMax: 2}); const a = await map.encode({quality: 70}), b = await map.encode(70); @@ -727,7 +722,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const base = new Image(20, 20).fill(0xff0000ff); assert(base.clone().rotate(45, {resize: false}).width === 20 && base.clone().rotate(45).width > 20, 'rotate options'); const navy = new Image(10, 10).fill(0x00002aff).drawBox(0, 0, 3, 3, 0xc8c800ff); - assert(navy.dominantColor({threshold: 0}) === 0x00002aff && navy.dominantColor({ignoreBlack: true}) === navy.dominantColor(true), 'dominantColor options'); + assert(navy.dominantColor({threshold: 0}) === 0x00002aff, 'dominantColor options'); } // type checks take any binary data @@ -740,9 +735,9 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // blur and sharpen: uniform images stay as they are, transparent pixels don't bleed their color { const flat = new Image(16, 16).fill(0x336699ff); - for (const [name, op] of [['blur', i => i.blur(3)], ['blur small', i => i.blur(0.8)], ['sharpen', i => i.sharpen(2, 1.5)]]) + for (const [name, op] of [['blur', i => i.blur(3)], ['blur small', i => i.blur(0.8)], ['sharpen', i => i.sharpen({amount: 2, radius: 1.5})]]) assert(Buffer.from(op(flat.clone()).bitmap).equals(Buffer.from(flat.bitmap)), `${name} changed a uniform image`); - assert(Buffer.from(flat.clone().blur(0).bitmap).equals(Buffer.from(flat.bitmap)) && Buffer.from(flat.clone().sharpen(0).bitmap).equals(Buffer.from(flat.bitmap)), 'blur(0)/sharpen(0) should do nothing'); + assert(Buffer.from(flat.clone().blur(0).bitmap).equals(Buffer.from(flat.bitmap)) && Buffer.from(flat.clone().sharpen({amount: 0}).bitmap).equals(Buffer.from(flat.bitmap)), 'blur(0)/sharpen(0) should do nothing'); const half = new Image(16, 16).fill((x) => x < 8 ? 0xff000000 : 0x0000ffff).blur(2); for (let x = 0; x < 16; x++) { @@ -751,10 +746,10 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); } const edge = new Image(16, 1).fill((x) => x < 8 ? 0x000000ff : 0xffffffff); - const sharpened = edge.clone().sharpen(1, 1); + const sharpened = edge.clone().sharpen({amount: 1, radius: 1}); assert(rgba(sharpened, 7, 0)[0] === 0 && rgba(sharpened, 8, 0)[0] === 255, 'sharpen should keep a hard edge hard'); const soft = new Image(16, 1).fill((x) => Colors.fromRGB(x * 16, x * 16, x * 16)).blur(1); - const sharp = soft.clone().sharpen(3, 2); + const sharp = soft.clone().sharpen({amount: 3, radius: 2}); assert(rgba(sharp, 15, 0)[0] >= rgba(soft, 15, 0)[0] && rgba(sharp, 0, 0)[0] <= rgba(soft, 0, 0)[0], 'sharpen should increase contrast'); } @@ -772,7 +767,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); assert(rgba(px(0xffffff80).sepia(), 0, 0).join() === '255,255,239,128', 'sepia, got ' + rgba(px(0xffffff80).sepia(), 0, 0)); let threw = 0; - for (const op of [i => i.brightness(-1), i => i.contrast(NaN), i => i.gamma(0), i => i.blur(-1), i => i.sharpen(1, 0)]) + for (const op of [i => i.brightness(-1), i => i.contrast(NaN), i => i.gamma(0), i => i.blur(-1), i => i.sharpen({amount: 1, radius: 0})]) try { op(px(0)); } catch { threw++; } assert(threw === 5, 'invalid adjustment arguments should throw'); } @@ -781,13 +776,13 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); { const base = new Image(2, 1).fill((x) => x ? 0x808080ff : 0x00000000); const top = new Image(2, 1).fill(0x4080c0ff); - assert(Buffer.from(base.clone().composite(top, 0, 0, {opacity: 0}).bitmap).equals(Buffer.from(base.bitmap)), 'opacity 0 should change nothing'); - assert(rgba(base.clone().composite(top, 0, 0, {opacity: 0.5}), 1, 0).join() === '96,128,160,255', 'opacity 0.5'); - assert(rgba(base.clone().composite(top, 0, 0, {mode: 'multiply'}), 1, 0).join() === '32,64,96,255', 'multiply'); - assert(rgba(base.clone().composite(top, 0, 0, {mode: 'screen'}), 1, 0).join() === '160,192,224,255', 'screen'); + assert(Buffer.from(base.clone().composite(top, {x: 0, y: 0, opacity: 0}).bitmap).equals(Buffer.from(base.bitmap)), 'opacity 0 should change nothing'); + assert(rgba(base.clone().composite(top, {x: 0, y: 0, opacity: 0.5}), 1, 0).join() === '96,128,160,255', 'opacity 0.5'); + assert(rgba(base.clone().composite(top, {x: 0, y: 0, mode: 'multiply'}), 1, 0).join() === '32,64,96,255', 'multiply'); + assert(rgba(base.clone().composite(top, {x: 0, y: 0, mode: 'screen'}), 1, 0).join() === '160,192,224,255', 'screen'); // over a transparent backdrop blend modes draw the plain source - assert(rgba(base.clone().composite(top, 0, 0, {mode: 'multiply'}), 0, 0).join() === '64,128,192,255', 'blend over transparency'); - assert(Buffer.from(base.clone().composite(top, 0, 0, {mode: 'normal', opacity: 1}).bitmap).equals(Buffer.from(base.clone().composite(top).bitmap)), 'normal mode'); + assert(rgba(base.clone().composite(top, {x: 0, y: 0, mode: 'multiply'}), 0, 0).join() === '64,128,192,255', 'blend over transparency'); + assert(Buffer.from(base.clone().composite(top, {x: 0, y: 0, mode: 'normal', opacity: 1}).bitmap).equals(Buffer.from(base.clone().composite(top).bitmap)), 'normal mode'); // normal mode with an opacity (source over with the source alpha scaled) matches the general compositing model, // also for translucent pixels, off the edges @@ -797,13 +792,13 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const backdrop = new Image(24, 16).fill(pixel), source = new Image(16, 24).fill(pixel); for (const opacity of [0.3, 0.85, 0.999]) { const expected = backdrop.clone().__blend__(source, 12, -4, opacity, (_, s) => s).bitmap; - const actual = backdrop.clone().composite(source, 12, -4, {opacity}).bitmap; + const actual = backdrop.clone().composite(source, {x: 12, y: -4, opacity}).bitmap; assert(actual.every((v, i) => Math.abs(v - expected[i]) <= 1), `normal mode with opacity ${opacity} differs from the compositing model`); } let threw = 0; for (const options of [{mode: 'foo'}, {mode: 'toString'}, {opacity: 2}, {opacity: NaN}]) - try { base.clone().composite(top, 0, 0, options); } catch { threw++; } + try { base.clone().composite(top, {x: 0, y: 0, ...options}); } catch { threw++; } assert(threw === 4, 'invalid composite options should throw'); } @@ -879,7 +874,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); assert(Buffer.from(back.bitmap).equals(Buffer.from(image.bitmap)), 'colorToAlpha: compositing over the color should give the original'); const edge = cut.getRGBAAt(32, 20); assert(edge[3] > 0 && edge[3] < 255 && edge[0] === 255 && edge[1] === 0, 'colorToAlpha: antialiased edges should become translucent red'); - const loose = image.clone().colorToAlpha(0xffffffff, {transparencyThreshold: 0.5}); + const loose = image.clone().colorToAlpha({color: 0xffffffff, transparencyThreshold: 0.5}); assert(loose.getRGBAAt(32, 20)[3] < edge[3], 'colorToAlpha: the threshold should make more of the edge transparent'); } @@ -1043,11 +1038,11 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const calls = { rgbaToColor: v => Colors.fromRGBA(v, 0, 0, 255), hslaToColor: v => Colors.fromHSLA(v, 0.5, 0.5, 1), colorToRGBA: v => Colors.toRGBA(v), getPixelAt: v => image().getPixelAt(v, 1), setPixelAt: v => image().setPixelAt(1, 1, v), fill: v => image().fill(v), contain: v => image().contain(v, 4), - crop: v => image().crop(v, 0, 2, 2), trim: v => image().trim(v), drawBox: v => image().drawBox(0, 0, v, 2, 0), drawCircle: v => image().drawCircle(2, 2, v, 0), + crop: v => image().crop(v, 0, 2, 2), trim: v => image().trim({color: v}), drawBox: v => image().drawBox(0, 0, v, 2, 0), drawCircle: v => image().drawCircle(2, 2, v, 0), cropCircle: v => image().cropCircle({ feathering: v }), opacity: v => image().opacity(v), red: v => image().red(v), lightness: v => image().lightness(v), - composite: v => image().composite(image(), v, 0), hueShift: v => image().hueShift(v), brightness: v => image().brightness(v), gamma: v => image().gamma(v), - colorToAlpha: v => image().colorToAlpha(v), rotate: v => image().rotate(v, false), gradient: v => Colors.gradient({0: v, 1: 0}), - roundCorners: v => image().roundCorners(v), fisheye: v => image().fisheye(v), encodePNG: v => image().encodePNG(v), encodeJPEG: v => image().encodeJPEG(v), + composite: v => image().composite(image(), {x: v, y: 0}), hueShift: v => image().hueShift(v), brightness: v => image().brightness(v), gamma: v => image().gamma(v), + colorToAlpha: v => image().colorToAlpha({color: v}), rotate: v => image().rotate(v, {resize: false}), gradient: v => Colors.gradient({0: v, 1: 0}), + roundCorners: v => image().roundCorners({radius: v}), fisheye: v => image().fisheye({strength: v}), encodePNG: v => image().encodePNG(v), encodeJPEG: v => image().encodeJPEG(v), frame: v => new Frame(4, 4, v), animation: v => new Animation([new Frame(4, 4)], v), }; for (const [name, call] of Object.entries(calls)) @@ -1069,7 +1064,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // (no limit is a valid layout) new TextLayout({maxWidth: Infinity, maxHeight: Infinity}); - const strings = image().crop('1', '1', '4', '4').rotate('45').opacity('0.5').composite(image(), '1', '2'); + const strings = image().crop('1', '1', '4', '4').rotate('45').opacity('0.5').composite(image(), {x: '1', y: '2'}); if (strings.width !== 6) panic('numeric strings should keep working'); } { diff --git a/tests/rotate.js b/tests/rotate.js index ee0a6762..5613b1d9 100644 --- a/tests/rotate.js +++ b/tests/rotate.js @@ -23,7 +23,7 @@ const panic = msg => { { const binary = await fs.readFile('./tests/targets/image.png'); const image = await Image.decode(binary); - image.rotate(45, false); + image.rotate(45, {resize: false}); const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); diff --git a/tests/trim.js b/tests/trim.js index a6783b74..01d2e733 100644 --- a/tests/trim.js +++ b/tests/trim.js @@ -23,18 +23,18 @@ const size = image => `${image.width}x${image.height}`; { const make = () => new Image(6, 6).fill(0xffffffff).drawBox(3, 2, 2, 3, 0x000000ff); if (size(make().trim()) !== '2x3') panic('trim: solid background not trimmed'); - if (size(make().trim(0x000000ff)) !== '6x6') panic('trim: explicit color should only trim that color'); + if (size(make().trim({color: 0x000000ff})) !== '6x6') panic('trim: explicit color should only trim that color'); const marked = make().setPixelAt(1, 1, 0xff0000ff); - if (size(marked.trim(0xffffffff)) !== '4x4') panic(`trim: explicit color: ${size(marked)}`); + if (size(marked.trim({color: 0xffffffff})) !== '4x4') panic(`trim: explicit color: ${size(marked)}`); } // tolerance, e.g. for JPEG artifacts { const make = () => new Image(5, 5).fill(0xffffffff).setPixelAt(1, 0, 0xf8f8f8ff).setPixelAt(3, 3, 0x000000ff); if (size(make().trim()) !== '3x4') panic('trim: without tolerance, near colors stay'); - if (size(make().trim(undefined, {tolerance: 0.05})) !== '1x1') panic('trim: tolerance has no effect'); + if (size(make().trim({tolerance: 0.05})) !== '1x1') panic('trim: tolerance has no effect'); for (const tolerance of [-0.1, 1.5, NaN]) - if (!(() => { try { make().trim(undefined, {tolerance}); } catch { return true; } })()) panic(`trim: tolerance ${tolerance} accepted`); + if (!(() => { try { make().trim({tolerance}); } catch { return true; } })()) panic(`trim: tolerance ${tolerance} accepted`); } // an image of only the color stays unchanged From ceb2b09adb15445d5e8f32e4d0b681b80fe363d0 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 16:51:02 +0000 Subject: [PATCH 013/188] remove the positional arguments of the encoders --- CHANGELOG.md | 2 ++ src/animation.ts | 30 +++++++----------------------- src/gainmap.ts | 14 ++++---------- src/gif.ts | 2 +- src/image.ts | 39 +++++++++------------------------------ tests/circle.js | 8 ++++---- tests/crop.js | 4 ++-- tests/edge/smoke.mjs | 2 +- tests/effects.js | 2 +- tests/fill.js | 4 ++-- tests/fisheye.js | 2 +- tests/flip.js | 4 ++-- tests/font.js | 16 ++++++++-------- tests/gainmap.js | 8 ++++---- tests/image.js | 2 +- tests/invert.js | 8 ++++---- tests/jpeg.js | 4 ++-- tests/metadata.js | 6 +++--- tests/options-only.js | 21 ++++++++++++++++++++- tests/options.js | 15 ++------------- tests/png.js | 2 +- tests/regressions.js | 10 ++++------ tests/resize.js | 2 +- tests/rotate.js | 10 +++++----- tests/svg-images.js | 10 +++++----- tests/svg.js | 8 ++++---- 26 files changed, 100 insertions(+), 135 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fae98e0..f1cf586e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ - **The positional arguments of `composite()`, `rotate()`, `sharpen()`, `dominantColor()`, `roundCorners()`, `fisheye()`, `trim()` and `colorToAlpha()` are removed**: the values go in the options object, which is the only second argument now. `image.composite(logo, 10, 20, { opacity: 0.5 })` is `image.composite(logo, { x: 10, y: 20, opacity: 0.5 })`, `image.sharpen(2, 3)` is `image.sharpen({ amount: 2, radius: 3 })`, `image.rotate(45, false)` is `image.rotate(45, { resize: false })`, `image.dominantColor(true, true, 15)` is `image.dominantColor({ ignoreBlack: true, ignoreWhite: true, threshold: 15 })`, `image.roundCorners(8)` is `image.roundCorners({ radius: 8 })` and `image.fisheye(3)` is `image.fisheye({ strength: 3 })`. `trim(color, options)` and `colorToAlpha(color, options)` take the color as `color` in the options (`trim({ color, tolerance })`, `colorToAlpha({ color, transparencyThreshold })`), and `Frame.trim()` works the same way. A value in the old position throws a `TypeError`. +- **The positional arguments of the encoders are removed**: `encodePNG(compression, metadata)` and `encodePNG(metadata)` are `encodePNG({ compression, metadata })`, `encodeJPEG(quality)` is `encodeJPEG({ quality })`, `Animation.encodeGIF(quality)` is `encodeGIF({ quality })` and `Animation.encodeAPNG(compression)` is `encodeAPNG({ compression })`, and `GainMap.encode(quality)` is `encode({ quality })`. For example, `image.encodePNG(1, { author })` is `image.encodePNG({ compression: 1, metadata: { author } })`, and the metadata alone, `image.encodePNG({ author })`, throws a `TypeError` naming `{ metadata }`, because it would be ignored otherwise. + ## 3.20.1 ### Performance diff --git a/src/animation.ts b/src/animation.ts index 8a8796d4..87065804 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -12,7 +12,7 @@ import { Frame, FrameDisposalModes } from './frame.ts'; import { ImageType } from './image-type.ts'; import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts'; import { Metadata, resolveMetadata, pngText, exifData, gifComments } from './metadata.ts'; -import { isOptions, finite, compressionLevel } from './util/options.ts'; +import { isOptions, checkOptions, finite, compressionLevel } from './util/options.ts'; import { MAX_PIXELS, decoding } from './util/limits.ts'; import { AnimationEncoder } from './animation-encoder.ts'; @@ -276,17 +276,9 @@ export class Animation extends Array { * @param options See {@link GIFEncodeOptions} */ encodeGIF(options?: GIFEncodeOptions): Promise; - /** - * Encodes the animation as GIF. Like all encoders, it stores each frame as the area that changed since the frame - * before and merges identical frames (unless the `optimize` option is false), so the file decodes to what the - * animation shows, but not necessarily to the same frames. Semi-transparent pixels become opaque or transparent - * after the frames are composited - * @param quality 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above) - * @deprecated Pass {@link GIFEncodeOptions} instead, the positional arguments will be removed in 4.0 - */ - encodeGIF(quality?: number): Promise; - async encodeGIF(qualityOrOptions: number | GIFEncodeOptions = 95): Promise { - const { quality = 95, metadata = {}, optimize = true } = isOptions(qualityOrOptions) ? qualityOrOptions : { quality: qualityOrOptions }; + async encodeGIF(options: GIFEncodeOptions = {}): Promise { + checkOptions(options, 'encodeGIF({ quality: 80 })'); + const { quality = 95, metadata = {}, optimize = true } = options; this.__validate__(); const frames = optimize ? this.__optimized__({ replace: false, even: false, alpha: 'frame', palette: 256, cover: true }).map(f => ({ ...f, dispose: f.dispose ? 3 : 1 })) @@ -313,17 +305,9 @@ export class Animation extends Array { * @param options See {@link APNGEncodeOptions} */ encodeAPNG(options?: APNGEncodeOptions): Promise; - /** - * Encodes the animation as APNG (animated PNG, lossless and with full alpha). Viewers without APNG support show - * the first frame. Each frame is stored as the area that changed since the frame before, identical frames are - * merged (unless the `optimize` option is false): the file shows exactly the same animation, but doesn't - * necessarily decode to the same frames - * @param compression The zlib compression level (0-9), higher is smaller but slower. Default: `4` - * @deprecated Pass {@link APNGEncodeOptions} instead, the positional arguments will be removed in 4.0 - */ - encodeAPNG(compression?: number): Promise; - async encodeAPNG(compressionOrOptions: number | APNGEncodeOptions = 4): Promise { - const { compression = 4, metadata = {}, optimize = true } = isOptions(compressionOrOptions) ? compressionOrOptions : { compression: compressionOrOptions }; + async encodeAPNG(options: APNGEncodeOptions = {}): Promise { + checkOptions(options, 'encodeAPNG({ compression: 9 })'); + const { compression = 4, metadata = {}, optimize = true } = options; this.__validate__(); if (!this.length) throw new Error('An animation needs at least one frame'); diff --git a/src/gainmap.ts b/src/gainmap.ts index fce0faf8..a07b83f9 100644 --- a/src/gainmap.ts +++ b/src/gainmap.ts @@ -2,7 +2,7 @@ import { Image } from './image.ts'; import * as jpeg from './codecs/jpeg.ts'; import * as codec from './codecs/gainmap.ts'; import { BinaryData, view } from './util/mem.ts'; -import { finite, isOptions, quality as checkQuality } from './util/options.ts'; +import { finite, checkOptions, quality as checkQuality } from './util/options.ts'; /** Options of {@link GainMap.encode} */ export interface GainMapEncodeOptions { @@ -270,15 +270,9 @@ export class GainMap { * @param options See {@link GainMapEncodeOptions} */ encode(options?: GainMapEncodeOptions): Promise; - /** - * Encodes the gain map alone: a JPEG with its parameters (hdrgm XMP and ISO 21496-1), like the second image in an - * Ultra HDR JPEG - * @param quality The JPEG quality (1-100). Default: `90` - * @deprecated Pass {@link GainMapEncodeOptions} instead, the positional arguments will be removed in 4.0 - */ - encode(quality?: number): Promise; - async encode(qualityOrOptions?: number | GainMapEncodeOptions): Promise { - let quality = isOptions(qualityOrOptions) ? qualityOrOptions.quality : qualityOrOptions; + async encode(options?: GainMapEncodeOptions): Promise { + checkOptions(options, 'encode({ quality: 80 })'); + let quality = options?.quality; if (quality !== undefined) quality = Math.max(1, checkQuality(quality)); return codec.mapWithMetadata(this.__body__(quality), this.__params__()); } diff --git a/src/gif.ts b/src/gif.ts index 5eaeaab7..9eb1a489 100644 --- a/src/gif.ts +++ b/src/gif.ts @@ -34,7 +34,7 @@ export class GIF extends Animation { * @deprecated Use {@link Animation.encodeGIF} */ async encode(qualityOrOptions?: number | GIFEncodeOptions): Promise { - return this.encodeGIF(qualityOrOptions as number); + return this.encodeGIF(isOptions(qualityOrOptions) || qualityOrOptions === undefined ? qualityOrOptions : { quality: qualityOrOptions }); } /** diff --git a/src/image.ts b/src/image.ts index 50ccdaa2..8ce264be 100644 --- a/src/image.ts +++ b/src/image.ts @@ -2566,28 +2566,12 @@ export class Image { * @return The encoded data */ encodePNG(options?: PNGEncodeOptions): Promise; - /** - * Encodes the image into a PNG - * @param compression The compression level (0-9). Default: `4` - * @param metadata Metadata to include, see {@link Metadata} - * @return The encoded data - * @deprecated Pass {@link PNGEncodeOptions} instead, the positional arguments will be removed in 4.0 - */ - encodePNG(compression?: number, metadata?: Metadata): Promise; - /** - * Encodes the image into a PNG with compression level 4 - * @param metadata Metadata to include, see {@link Metadata} - * @return The encoded data - * @deprecated Pass {@link PNGEncodeOptions} instead, the positional arguments will be removed in 4.0 - */ - encodePNG(metadata?: Metadata): Promise; - async encodePNG(compressionOrOptions: number | PNGEncodeOptions | Metadata = 4, metadataArgument: Metadata = {}): Promise { - let compression = 4, metadata = metadataArgument; - if (!isOptions(compressionOrOptions)) compression = compressionOrOptions; - // the options and the metadata have no key in common: an object with neither is empty, the same either way - else if ('compression' in compressionOrOptions || 'metadata' in compressionOrOptions) - ({ compression = 4, metadata = {} } = compressionOrOptions); - else metadata = compressionOrOptions as Metadata; + async encodePNG(options: PNGEncodeOptions = {}): Promise { + checkOptions(options, 'encodePNG({ compression: 9, metadata: { author: \'me\' } })'); + // the metadata of 3.x passed as the only argument would be ignored: it has to go in { metadata } + for (const key of Object.keys(options)) + if (key !== 'compression' && key !== 'metadata') throw new TypeError(`encodePNG() takes the metadata in { metadata }, like encodePNG({ metadata: { ${key}: ... } }), not ${key} at the top level`); + const { compression = 4, metadata = {} } = options; return png.encodeRGBA(this.__bytes__(), this.width, this.height, compressionLevel(compression), pngText(resolveMetadata(metadata))); } @@ -2597,14 +2581,9 @@ export class Image { * @param options See {@link JPEGEncodeOptions} */ encodeJPEG(options?: JPEGEncodeOptions): Promise; - /** - * Encodes the image into a JPEG - * @param [quality=90] The JPEG quality to use (1-100), chroma subsampling is disabled above 90 - * @deprecated Pass {@link JPEGEncodeOptions} instead, the positional arguments will be removed in 4.0 - */ - encodeJPEG(quality?: number): Promise; - async encodeJPEG(qualityOrOptions: number | JPEGEncodeOptions = 90): Promise { - const { quality = 90, metadata = {}, gainMap: gainMapOption = true } = isOptions(qualityOrOptions) ? qualityOrOptions : { quality: qualityOrOptions }; + async encodeJPEG(options: JPEGEncodeOptions = {}): Promise { + checkOptions(options, 'encodeJPEG({ quality: 80 })'); + const { quality = 90, metadata = {}, gainMap: gainMapOption = true } = options; const m = resolveMetadata(metadata); const base = { exif: exifData(m), comment: m.comment ? utf8(m.comment) : undefined }; const gainMap = this.__gain_map__; diff --git a/tests/circle.js b/tests/circle.js index 05c05bdb..58069bc7 100644 --- a/tests/circle.js +++ b/tests/circle.js @@ -12,7 +12,7 @@ const panic = message => { const image = new Image(512, 512); image.drawCircle(256, 256, 128, 0xffffffff); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/circle.png', encoded); @@ -25,7 +25,7 @@ const panic = message => { const image = new Image(512, 512); image.drawCircle(256, 256, 320, 0x000000ff); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/circle2.png', encoded); @@ -39,7 +39,7 @@ const panic = message => { image.fill(0x000000ff); image.cropCircle(); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/circle3.png', encoded); @@ -53,7 +53,7 @@ const panic = message => { image.fill(0x000000ff); image.cropCircle({ size: 'cover', feathering: .5 }); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/circle4.png', encoded); diff --git a/tests/crop.js b/tests/crop.js index 5a7b3331..403e7168 100644 --- a/tests/crop.js +++ b/tests/crop.js @@ -7,7 +7,7 @@ const samePixels = require('./util/pixels'); image.fill((x, y) => Colors.fromHSL((x + y) / (image.width + image.height), 1, .5)); image.crop(128, 128, 128, 128); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/crop.png', encoded); @@ -23,7 +23,7 @@ const samePixels = require('./util/pixels'); const source = new Image(512, 512).fill((x, y) => Colors.fromHSL((x + y) / 1024, 1, .5)); const before = Buffer.from(source.bitmap); const region = source.region(128, 128, 128, 128); - if (!await samePixels(target, await region.encodePNG(1, {creationTime: 0, software: ''}))) panic('region: wrong pixels'); + if (!await samePixels(target, await region.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}))) panic('region: wrong pixels'); if (!Buffer.from(source.bitmap).equals(before)) panic('region: changed the source'); region.fill(0); if (!Buffer.from(source.bitmap).equals(before)) panic('region: shares memory with the source'); diff --git a/tests/edge/smoke.mjs b/tests/edge/smoke.mjs index 76f7039a..be3cd7db 100644 --- a/tests/edge/smoke.mjs +++ b/tests/edge/smoke.mjs @@ -21,7 +21,7 @@ export async function smoke() { return `${data.length} B`; }; await step('png', async () => back(await alpha.encodePNG())); - await step('jpeg', async () => back(await photo.encodeJPEG(80))); + await step('jpeg', async () => back(await photo.encodeJPEG({quality: 80}))); await step('webp lossless', async () => back(await alpha.encodeWebP())); await step('webp lossy', async () => back(await alpha.encodeWebP({ quality: 75 }))); await step('avif', async () => back(await alpha.encodeAVIF({ effort: 0 }))); diff --git a/tests/effects.js b/tests/effects.js index fd3d8883..d4052510 100644 --- a/tests/effects.js +++ b/tests/effects.js @@ -20,7 +20,7 @@ const samePixels = require('./util/pixels'); tiles.forEach((t, i) => image.composite(t, {x: (i % 5) * 128, y: (i / 5 | 0) * 128})); image.composite(await Image.renderText('Outlined', { font, size: 56, color: 0xffffffff, strokeWidth: 5 }), {x: 8, y: 4 * 128 + 4}); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/effects.png', encoded); diff --git a/tests/fill.js b/tests/fill.js index 829eff72..263a23b5 100644 --- a/tests/fill.js +++ b/tests/fill.js @@ -12,7 +12,7 @@ const panic = message => { const image = new Image(512, 512); image.fill(0xff8000ff); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/fill-static.png', encoded); @@ -25,7 +25,7 @@ const panic = message => { const image = new Image(512, 512); image.fill((x, y) => Colors.fromHSL((x + y) / (image.width + image.height), 1, .5)); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/fill-func.png', encoded); diff --git a/tests/fisheye.js b/tests/fisheye.js index 1d59cd67..44dff86e 100644 --- a/tests/fisheye.js +++ b/tests/fisheye.js @@ -8,7 +8,7 @@ const samePixels = require('./util/pixels'); const image = await Image.decode(input); image.fisheye(); - const output = await image.encodePNG(1, {creationTime: 0, software: ''}); + const output = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/fisheye.png', output); diff --git a/tests/flip.js b/tests/flip.js index 8734b431..5e5e19ae 100644 --- a/tests/flip.js +++ b/tests/flip.js @@ -9,7 +9,7 @@ const samePixels = require('./util/pixels'); const image = await Image.decode(input); image.flip('horizontal'); - const output = await image.encodePNG(1, {creationTime: 0, software: ''}); + const output = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/flip-horizontal.png', output); @@ -24,7 +24,7 @@ const samePixels = require('./util/pixels'); const image = await Image.decode(input); image.flip('vertical'); - const output = await image.encodePNG(1, {creationTime: 0, software: ''}); + const output = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/flip-vertical.png', output); diff --git a/tests/font.js b/tests/font.js index 53decb4e..4e39050c 100644 --- a/tests/font.js +++ b/tests/font.js @@ -39,7 +39,7 @@ const panic = message => { { const font = await Image.renderText('ThE qUiCk', { font: await fs.readFile('./tests/fonts/carbon phyber.ttf'), size: 128 }); - const encoded = await font.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await font.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/font-1.png', encoded); @@ -50,7 +50,7 @@ const panic = message => { { const font = await Image.renderText('BrOwN fOx', { font: await fs.readFile('./tests/fonts/ethnocentric rg.ttf'), size: 128 }); - const encoded = await font.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await font.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/font-2.png', encoded); @@ -61,7 +61,7 @@ const panic = message => { { const font = await Image.renderText('Extra\n-Test\nnewline', { font: await fs.readFile('./tests/fonts/opensans bold.ttf'), size: 128 }); - const encoded = await font.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await font.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/font-3.png', encoded); @@ -73,7 +73,7 @@ const panic = message => { { // OpenType font with CFF outlines const font = await Image.renderText('Quartz glyph job, vex!', { font: await fs.readFile('./tests/fonts/sourcesans3 regular.otf'), size: 64 }); - const encoded = await font.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await font.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/font-4.png', encoded); @@ -248,7 +248,7 @@ const panic = message => { for (const [file, target] of [['noto color emoji subset.ttf', 'emoji-cbdt'], ['twemoji mozilla subset.ttf', 'emoji-colr'], ['noto colrv1 subset.ttf', 'emoji-colrv1']]) { const emoji = await fs.readFile(`./tests/fonts/${file}`); const image = await Image.renderText('Hi 😀🔥 👍🏽 🇩🇪 1️⃣ ❤️ 👨‍👩‍👧', { font: [sans, emoji], size: 48, color: 0x000000ff }); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile(`./tests/targets/${target}.png`, encoded); if (!await samePixels(await fs.readFile(`./tests/targets/${target}.png`), encoded)) panic(`${target} doesn't match`); @@ -289,7 +289,7 @@ const panic = message => { const font = await fs.readFile('./tests/fonts/colrv1 paints.ttf'); const glyphs = Array.from({length: 17}, (_, k) => String.fromCodePoint(0xe000 + k)).join(''); const image = await Image.renderText(glyphs, { font, size: 48, color: 0x00aaaaff }); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/colrv1-paints.png', encoded); if (!await samePixels(await fs.readFile('./tests/targets/colrv1-paints.png'), encoded)) panic('colrv1-paints doesn\'t match'); @@ -374,7 +374,7 @@ const panic = message => { .map(name => fs.readFile(`./tests/fonts/${name}`))); const text = 'Hi שָׁלוֹם עוֹלָם (2024) and مرحبا بالعالم!\nمرحبا بالعالم، هذا نص (2024)!\nनमस्ते क्षत्रिय र्क्ष'; const font = await Image.renderText(text, { font: fonts, size: 40, color: 0xffffffff, layout: new TextLayout({maxWidth: 420, horizontalAlign: 'right'}) }); - const encoded = await font.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await font.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/font-bidi.png', encoded); @@ -386,7 +386,7 @@ const panic = message => { const line = await Image.renderText('مرحبا Hi', { font: fonts, size: 40, color: 0xffffffff }); const arabic = await Image.renderText('مرحبا', { font: fonts, size: 40, color: 0xffffffff }); const right = line.clone().crop(line.width - arabic.width, 0, arabic.width, line.height); - if (!await samePixels(await arabic.encodePNG(1, {creationTime: 0, software: ''}), await right.encodePNG(1, {creationTime: 0, software: ''}))) + if (!await samePixels(await arabic.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}), await right.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}))) panic('the right to left paragraph should start on the right'); } diff --git a/tests/gainmap.js b/tests/gainmap.js index 276facd1..f442983c 100644 --- a/tests/gainmap.js +++ b/tests/gainmap.js @@ -49,7 +49,7 @@ const meanValue = (image, left, top, right, bottom) => { // out for (const name of ['single-channel', 'multi-channel']) { const image = await Image.decode(files[name]); - const encoded = await image.encodeJPEG(90); + const encoded = await image.encodeJPEG({quality: 90}); const plain = await image.encodeJPEG({quality: 90, gainMap: false}); const back = await Image.decode(encoded); if (!back.gainMap || !close(back.gainMap.gainMapMax, image.gainMap.gainMapMax) || !close(back.gainMap.gainMapMin, image.gainMap.gainMapMin) || back.gainMap.hdrCapacityMax !== image.gainMap.hdrCapacityMax) @@ -91,7 +91,7 @@ const meanValue = (image, left, top, right, bottom) => { { const image = new Image(64, 48).fill(0x808080ff); image.gainMap = new GainMap(new Image(16, 12).fill((x, y) => Colors.fromRGBA(x * 16, y * 20, 255 - x * 16, 255)), {gainMapMax: [2, 3, 4], gainMapMin: -1, gamma: 2}); - const back = await Image.decode(await image.encodeJPEG(95)); + const back = await Image.decode(await image.encodeJPEG({quality: 95})); const map = back.gainMap; if (!map || map.channels !== 3 || !close(map.gainMapMax, [2, 3, 4]) || !close(map.gainMapMin, [-1, -1, -1]) || !close(map.gamma, [2, 2, 2]) || map.hdrCapacityMax !== 4 || map.width !== 16) panic(`hand made map: ${map ? [map.channels, map.gainMapMin, map.gainMapMax, map.gamma, map.hdrCapacityMax, map.width] : 'none'}`); @@ -182,7 +182,7 @@ const meanValue = (image, left, top, right, bottom) => { + '131072' + (headroomXMP ? `${headroomXMP}` : '') + 'urn:com:apple:photo:2020:aux:hdrgainmap'; - return Promise.all([new Image(128, 64).fill(0x808080ff).encodeJPEG(90), ramp.encodeJPEG(100)]).then(([base, map]) => { + return Promise.all([new Image(128, 64).fill(0x808080ff).encodeJPEG({quality: 90}), ramp.encodeJPEG({quality: 100})]).then(([base, map]) => { map = afterSOI(Buffer.from(map), segment(0xe1, Buffer.concat([Buffer.from('http://ns.adobe.com/xap/1.0/\0'), Buffer.from(xmp)]))); // the primary's MPF index (big endian), offsets from its TIFF header const mpf = Buffer.alloc(4 + 82); @@ -227,7 +227,7 @@ const meanValue = (image, left, top, right, bottom) => { if (Math.abs(want - got) > 0.03) panic(`Apple, ${name}: value ${x * 4} gives ${got} stops, not ${want}`); } // written as an Ultra HDR JPEG (ISO 21496-1 and hdrgm), the same boosts - const back = (await Image.decode(await image.encodeJPEG(90))).gainMap; + const back = (await Image.decode(await image.encodeJPEG({quality: 90}))).gainMap; if (!back || !close(back.gainMapMax, map.gainMapMax)) panic(`Apple, ${name}: not written back`); } } diff --git a/tests/image.js b/tests/image.js index c2c119f3..ec021d96 100644 --- a/tests/image.js +++ b/tests/image.js @@ -6,7 +6,7 @@ module.exports = (async () => { const image = new Image(128, 128); image.fill(x => Colors.fromHSL(x / image.width, 1, 0.5)); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/image.png', encoded); diff --git a/tests/invert.js b/tests/invert.js index 65863c2a..e44b6656 100644 --- a/tests/invert.js +++ b/tests/invert.js @@ -8,7 +8,7 @@ const samePixels = require('./util/pixels'); image.fill(0xff8000ff); image.invert(); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/invert.png', encoded); @@ -22,7 +22,7 @@ const samePixels = require('./util/pixels'); image.fill(0xff8000ff); image.invertLightness(); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/invert-lightness.png', encoded); @@ -36,7 +36,7 @@ const samePixels = require('./util/pixels'); image.fill((x, y) => Colors.fromRGB(x / 2, y / 2, 255 - x / 2)); image.invertValue(); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/invert-value.png', encoded); @@ -50,7 +50,7 @@ const samePixels = require('./util/pixels'); image.fill(0xff8000ff); image.hueShift(180); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/invert-hueshift.png', encoded); diff --git a/tests/jpeg.js b/tests/jpeg.js index 8f8385c8..8d2b764d 100644 --- a/tests/jpeg.js +++ b/tests/jpeg.js @@ -46,7 +46,7 @@ const withoutExif = data => { for (const [key, hash] of Object.entries(encoded)) { const [name, quality] = key.split('@'); const file = name === 'external' ? './tests/targets/external.jpg' : `./tests/jpegs/${name}.jpg`; - const data = withoutExif(await (await Image.decode(await fs.readFile(file))).encodeJPEG(Number(quality))); + const data = withoutExif(await (await Image.decode(await fs.readFile(file))).encodeJPEG({quality: Number(quality)})); if (crypto.createHash('sha256').update(data).digest('hex') !== hash) panic(`${key}: encoded bytes don't match cjpeg's`); } @@ -94,7 +94,7 @@ const withoutExif = data => { // encoding: higher quality must be larger and closer to the source let previous = null; for (const quality of [10, 50, 90, 100]) { - const encoded = await image.encodeJPEG(quality); + const encoded = await image.encodeJPEG({quality: quality}); const decoded = await Image.decode(encoded); if (decoded.width !== image.width || decoded.height !== image.height) panic(`q${quality}: dimensions don't match`); diff --git a/tests/metadata.js b/tests/metadata.js index 16387a4b..f392980a 100644 --- a/tests/metadata.js +++ b/tests/metadata.js @@ -15,7 +15,7 @@ const latin1 = (data, text) => Buffer.from(data).includes(Buffer.from(text, 'lat const animation = new Animation([Frame.from(image, 100), Frame.from(new Image(8, 4).fill(0x00ff00ff), 100)]); const encoders = { - png: m => image.encodePNG(m), + png: m => image.encodePNG({metadata: m}), jpeg: m => image.encodeJPEG({quality: 90, metadata: m}), webp: m => image.encodeWebP({metadata: m}), tga: m => image.encodeTGA({metadata: m}), @@ -41,7 +41,7 @@ const latin1 = (data, text) => Buffer.from(data).includes(Buffer.from(text, 'lat // PNG: tEXt is Latin-1, other text goes into iTXt (UTF-8), before the image data { - const png = Buffer.from(await image.encodePNG(metadata)); + const png = Buffer.from(await image.encodePNG({metadata: 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'); @@ -80,5 +80,5 @@ const latin1 = (data, text) => Buffer.from(data).includes(Buffer.from(text, 'lat if (tga[area + 494] !== 3) panic('tga: attributes type should be "useful alpha"'); } - if (await image.encodePNG({creationTime: 'not a date'}).then(() => true, () => false)) panic('an invalid creation time should throw'); + if (await image.encodePNG({metadata: {creationTime: 'not a date'}}).then(() => true, () => false)) panic('an invalid creation time should throw'); })(); diff --git a/tests/options-only.js b/tests/options-only.js index 0d558940..6071aa9d 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -1,4 +1,4 @@ -const {Image, Frame, Animation, ResizeModes} = require('..'); +const {Image, Frame, Animation, GainMap, ResizeModes} = require('..'); const panic = message => { console.error(message); @@ -78,6 +78,25 @@ const sameImage = (image, reference, label) => { if (solid.clone().roundCorners({radius: 0}).getPixelAt(0, 0) !== 0xffffffff) panic('roundCorners({ radius: 0 }) should not change the pixels'); if (solid.clone().rotate(90, {resize: false}).width !== 6) panic('rotate(90, { resize: false })'); + // the encoders take their values in the options object only; the metadata goes in { metadata } + const makeAnimation = () => new Animation([new Frame(4, 4)]); + const gainMap = new GainMap(new Image(4, 4).fill(0x808080ff), {gainMapMin: 0, gainMapMax: 2}); + for (const [label, call] of Object.entries({ + 'encodePNG(1)': () => photo.encodePNG(1), + 'encodePNG(1, { author })': () => photo.encodePNG(1, {author: 'me'}), + 'encodePNG({ author }) (3.x metadata)': () => photo.encodePNG({author: 'me'}), + 'encodeJPEG(80)': () => photo.encodeJPEG(80), + 'encodeGIF(10)': () => makeAnimation().encodeGIF(10), + 'encodeAPNG(9)': () => makeAnimation().encodeAPNG(9), + 'GainMap.encode(70)': () => gainMap.encode(70) + })) { + const error = await rejects(call, label); + if (!/takes/.test(error.message)) panic(`${label}: the message should name the form, got ${error.message}`); + } + if (!Buffer.from(await photo.encodePNG({compression: 1, metadata: {author: 'me'}})).includes('me')) panic('encodePNG({ metadata }): the metadata is missing'); + if (!(await photo.encodeJPEG({quality: 80})).length || !(await makeAnimation().encodeGIF({quality: 10})).length) panic('encoders with options'); + if (!(await gainMap.encode({quality: 70})).length || !(await gainMap.encode()).length) panic('GainMap.encode with options'); + // the options form still resizes; undefined stays fine if (photo.clone().resize(10, 6, {mode: ResizeModes.AREA}).width !== 10) panic('resize(10, 6, { mode })'); if (photo.clone().resize(10, 6).height !== 6) panic('resize(10, 6)'); diff --git a/tests/options.js b/tests/options.js index cda98325..7d02e0d7 100644 --- a/tests/options.js +++ b/tests/options.js @@ -24,13 +24,9 @@ const same = (name, a, b) => { same('roundCorners default', tile().roundCorners(), tile().roundCorners({})); same('fisheye default', tile().fisheye(), tile().fisheye({})); - // encoders: decoded, since the metadata has a creation time - const pixelsOf = async data => (await Image.decode(data)).bitmap; - same('encode', await pixelsOf(await tile().encodePNG(9)), await pixelsOf(await tile().encodePNG({compression: 9}))); - same('encodeJPEG', await pixelsOf(await tile().encodeJPEG(40)), await pixelsOf(await tile().encodeJPEG({quality: 40}))); - // metadata as the first argument (3.x), and in the options + // the metadata goes in { metadata } const author = 'options test'; - for (const data of [await tile().encodePNG({author}), await tile().encodePNG({metadata: {author}}), await tile().encodePNG(1, {author})]) + for (const data of [await tile().encodePNG({metadata: {author}}), await tile().encodePNG({compression: 1, metadata: {author}})]) if (!Buffer.from(data).includes(author)) panic('encode: metadata missing'); if (!Buffer.from(await tile().encodeJPEG({metadata: {author}})).includes(author)) panic('encodeJPEG: metadata missing'); for (const method of ['encodeWebP', 'encodeTGA']) @@ -50,13 +46,6 @@ const same = (name, a, b) => { const frames = () => [Frame.from(tile(), {duration: 50}), Frame.from(tile().invert(), {duration: 50})]; if (new Animation(frames(), {loopCount: 2}).loopCount !== 2 || new GIF(frames(), {loopCount: 2}).loopCount !== 2) panic('Animation: loopCount not applied'); const animation = new Animation(frames()); - const sameFrames = async (name, x, y) => { - const fx = await Animation.decode(x), fy = await Animation.decode(y); - if (fx.length !== fy.length) panic(`${name}: options object and positional arguments differ`); - fx.forEach((f, i) => same(`${name} frame ${i}`, f, fy[i])); - }; - await sameFrames('encodeGIF', await animation.encodeGIF(10), await animation.encodeGIF({quality: 10})); - await sameFrames('encodeAPNG', await animation.encodeAPNG(9), await animation.encodeAPNG({compression: 9})); // (GIFs only store comments) for (const method of ['encodeGIF', 'encodeAPNG', 'encodeWebP']) if (!Buffer.from(await animation[method]({metadata: {author, comment: author}})).includes(author)) panic(`${method}: metadata missing`); diff --git a/tests/png.js b/tests/png.js index 1d92eefd..bfd8f8a2 100644 --- a/tests/png.js +++ b/tests/png.js @@ -52,7 +52,7 @@ const expected = { // encode/decode roundtrip const image = new Image(97, 61); image.fill((x, y) => Colors.fromRGBA(x * 2, y * 4, (x * y) & 0xff, (x + y) & 0xff)); - const decoded = await Image.decode(await image.encodePNG(3)); + const decoded = await Image.decode(await image.encodePNG({compression: 3})); if (!Buffer.from(decoded.bitmap).equals(Buffer.from(image.bitmap))) panic('roundtrip failed'); // a file cut off after its image data (in IEND) still decodes diff --git a/tests/regressions.js b/tests/regressions.js index 5f8f6b78..a6ddfdad 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -81,9 +81,9 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // gradients return unsigned colors like rgbaToColor assert(Colors.gradient({0: 0xff0000ff, 1: 0xff0000ff})(0.5) === 0xff0000ff, 'gradient: signed color'); - // PNG metadata as the only argument + // PNG metadata in { metadata } { - const encoded = await new Image(1, 1).encodePNG({title: 'regression'}); + const encoded = await new Image(1, 1).encodePNG({metadata: {title: 'regression'}}); assert(Buffer.from(encoded).includes(Buffer.from('regression')), 'encode(metadata): title missing'); } @@ -313,7 +313,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const gradient = new Image(512, 512).fill((x, y) => Colors.fromRGB(x / 2, y / 2, (x + y) / 4)); const size = (await gradient.encodePNG()).length; assert(size < 20000, `PNG: gradient should compress well with row filters, got ${size} bytes`); - assert(Buffer.from((await Image.decode(await gradient.encodePNG(9))).bitmap).equals(Buffer.from(gradient.bitmap)), 'PNG: filtered roundtrip failed'); + assert(Buffer.from((await Image.decode(await gradient.encodePNG({compression: 9}))).bitmap).equals(Buffer.from(gradient.bitmap)), 'PNG: filtered roundtrip failed'); } // invertValue inverts HSV value and invertLightness HSL lightness @@ -567,8 +567,6 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); assert(frame().trim({color: 0x102030ff}).xOffset === 5, 'Frame.trim({color}) moves the offsets'); assert(same(bordered().colorToAlpha(), bordered().colorToAlpha(undefined)) && same(bordered().colorToAlpha(), bordered().colorToAlpha({})), 'colorToAlpha() without arguments'); const map = new GainMap(new Image(4, 4).fill(0x808080ff), {gainMapMin: 0, gainMapMax: 2}); - const a = await map.encode({quality: 70}), b = await map.encode(70); - assert(Buffer.from(a).equals(Buffer.from(b)), 'GainMap.encode({quality}) vs encode(quality)'); assert(Buffer.from(await map.encode()).equals(Buffer.from(await map.encode({}))), 'GainMap.encode() without arguments'); } @@ -1042,7 +1040,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); cropCircle: v => image().cropCircle({ feathering: v }), opacity: v => image().opacity(v), red: v => image().red(v), lightness: v => image().lightness(v), composite: v => image().composite(image(), {x: v, y: 0}), hueShift: v => image().hueShift(v), brightness: v => image().brightness(v), gamma: v => image().gamma(v), colorToAlpha: v => image().colorToAlpha({color: v}), rotate: v => image().rotate(v, {resize: false}), gradient: v => Colors.gradient({0: v, 1: 0}), - roundCorners: v => image().roundCorners({radius: v}), fisheye: v => image().fisheye({strength: v}), encodePNG: v => image().encodePNG(v), encodeJPEG: v => image().encodeJPEG(v), + roundCorners: v => image().roundCorners({radius: v}), fisheye: v => image().fisheye({strength: v}), encodePNG: v => image().encodePNG({compression: v}), encodeJPEG: v => image().encodeJPEG({quality: v}), frame: v => new Frame(4, 4, v), animation: v => new Animation([new Frame(4, 4)], v), }; for (const [name, call] of Object.entries(calls)) diff --git a/tests/resize.js b/tests/resize.js index a3e6ffe6..5310586f 100644 --- a/tests/resize.js +++ b/tests/resize.js @@ -6,7 +6,7 @@ const samePixels = require('./util/pixels'); const image = await Image.decode(binary); image.resize({width: image.width / 4}); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/resize.png', encoded); diff --git a/tests/rotate.js b/tests/rotate.js index 5613b1d9..9d342631 100644 --- a/tests/rotate.js +++ b/tests/rotate.js @@ -12,7 +12,7 @@ const panic = msg => { const image = await Image.decode(binary); image.rotate(45); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/rotate-45.png', encoded); @@ -25,7 +25,7 @@ const panic = msg => { const image = await Image.decode(binary); image.rotate(45, {resize: false}); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/rotate-45-noresize.png', encoded); @@ -39,7 +39,7 @@ const panic = msg => { const image = await Image.decode(binary); image.rotate(90); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/rotate-90.png', encoded); @@ -52,7 +52,7 @@ const panic = msg => { const image = await Image.decode(binary); image.rotate(180); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/rotate-180.png', encoded); @@ -65,7 +65,7 @@ const panic = msg => { const image = await Image.decode(binary); image.rotate(270); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/rotate-270.png', encoded); diff --git a/tests/svg-images.js b/tests/svg-images.js index dfe4f4ce..0c2aeeb1 100644 --- a/tests/svg-images.js +++ b/tests/svg-images.js @@ -47,11 +47,11 @@ const crc32 = bytes => { const logo = new Image(4, 3).fill((x, y) => Colors.fromRGBA(x * 60, y * 80, 128, x === 0 ? 0 : 255)); const other = new Image(4, 3).fill((x, y) => Colors.fromRGBA(255 - x * 60, y * 40, 200, 255)); const opaque = new Image(4, 3).fill((x, y) => Colors.fromRGBA(x * 60, y * 80, 128, 255)); - const png = await logo.encodePNG(1); + const png = await logo.encodePNG({compression: 1}); const webp = await logo.encodeWebP(); - const jpeg = await opaque.encodeJPEG(90); + const jpeg = await opaque.encodeJPEG({quality: 90}); const gif = await new Animation([Frame.from(opaque)]).encodeGIF(); - const otherPNG = await other.encodePNG(1); + const otherPNG = await other.encodePNG({compression: 1}); // images given by href render exactly like the same images as data URIs, in every format and placement const formats = [['image/png', png], ['image/webp', webp], ['image/jpeg', jpeg], ['image/gif', gif]]; @@ -152,7 +152,7 @@ const crc32 = bytes => { // a huge declared image doesn't get around the decoding limit: it draws nothing, like a broken image { - const tiny = await new Image(1, 1).fill(0xffffffff).encodePNG(1); + const tiny = await new Image(1, 1).fill(0xffffffff).encodePNG({compression: 1}); const huge = Uint8Array.from(tiny); const view = new DataView(huge.buffer); view.setUint32(16, 20000); @@ -165,7 +165,7 @@ const crc32 = bytes => { // quantized images can be given too (the palette of an SVG's image) { - const reduced = await new Image(4, 3).fill((x, y) => Colors.fromRGBA(x * 60, y * 80, 128, 255)).quantize({colors: 4}).encodePNG(1); + const reduced = await new Image(4, 3).fill((x, y) => Colors.fromRGBA(x * 60, y * 80, 128, 255)).quantize({colors: 4}).encodePNG({compression: 1}); const result = await Image.renderSVG(svg(''), {images: {'logo.png': reduced}}); if (transparent(result)) panic('a reduced image was not drawn'); } diff --git a/tests/svg.js b/tests/svg.js index cf5dd2e2..b2941b47 100644 --- a/tests/svg.js +++ b/tests/svg.js @@ -11,7 +11,7 @@ const panic = message => { { const svg = await fs.readFile('./tests/svgs/potato.svg'); const image = await Image.renderSVG(svg.toString(), 256 / 36, SVGScaleModes.SCALE); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/potato.png', encoded); @@ -26,7 +26,7 @@ const panic = message => { const image = await Image.renderSVG(svg, 480, SVGScaleModes.WIDTH); if (image.width !== 480 || image.height !== 320) panic('features: unexpected size'); - const encoded = await image.encodePNG(1, {creationTime: 0, software: ''}); + const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) await fs.writeFile('./tests/targets/svg-features.png', encoded); @@ -51,9 +51,9 @@ const panic = message => { } const rect = ''; const gif = await new Animation([Frame.from(opaque)]).encodeGIF(); - const jpeg = await opaque.encodeJPEG(90); + const jpeg = await opaque.encodeJPEG({quality: 90}); const cases = [ - ['image/png', await source.encodePNG(1), source], + ['image/png', await source.encodePNG({compression: 1}), source], ['image/webp', await source.encodeWebP(), source], ['image/gif', gif, (await Animation.decode(gif))[0]], ['image/jpeg', jpeg, await Image.decode(jpeg)], From be2a27ba4304455ef057e391dd1080dbab248e83 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 16:57:32 +0000 Subject: [PATCH 014/188] remove the positional arguments of renderSVG --- CHANGELOG.md | 3 +++ src/image.ts | 14 +++----------- tests/options-only.js | 16 +++++++++++++++- tests/options.js | 3 +-- tests/regressions.js | 2 +- tests/svg.js | 4 ++-- 6 files changed, 25 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1cf586e..2ca52096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ - **The positional arguments of the encoders are removed**: `encodePNG(compression, metadata)` and `encodePNG(metadata)` are `encodePNG({ compression, metadata })`, `encodeJPEG(quality)` is `encodeJPEG({ quality })`, `Animation.encodeGIF(quality)` is `encodeGIF({ quality })` and `Animation.encodeAPNG(compression)` is `encodeAPNG({ compression })`, and `GainMap.encode(quality)` is `encode({ quality })`. For example, `image.encodePNG(1, { author })` is `image.encodePNG({ compression: 1, metadata: { author } })`, and the metadata alone, `image.encodePNG({ author })`, throws a `TypeError` naming `{ metadata }`, because it would be ignored otherwise. +- **The positional arguments of `Image.renderSVG()` are removed**: `Image.renderSVG(svg, size, mode)` is `Image.renderSVG(svg, { size, mode })`, which takes the same `fonts` and `images` as before: `Image.renderSVG(svg, 2, 'width')` is now `Image.renderSVG(svg, { size: 2, mode: 'width' })`. + + ## 3.20.1 ### Performance diff --git a/src/image.ts b/src/image.ts index 8ce264be..f8896376 100644 --- a/src/image.ts +++ b/src/image.ts @@ -2839,17 +2839,9 @@ export class Image { * @return The rendered SVG graphic */ static renderSVG(svg: string | BinaryData, options?: SVGOptions): Promise; - /** - * Creates a new image from the given SVG - * @param svg The SVG content - * @param size The size to use - * @param mode The SVG resizing mode to use (one of {@link SVGScaleModes.SCALE}, {@link SVGScaleModes.WIDTH}, {@link SVGScaleModes.HEIGHT}) - * @return The rendered SVG graphic - * @deprecated Pass {@link SVGOptions} instead, the positional arguments will be removed in 4.0 - */ - static renderSVG(svg: string | BinaryData, size?: number, mode?: SVGScaleMode): Promise; - static async renderSVG(svg: string | BinaryData, sizeOrOptions: number | SVGOptions = 1, modeArgument: SVGScaleMode = SVGScaleModes.SCALE): Promise { - const { size = 1, mode = SVGScaleModes.SCALE, fonts = [], images: givenImages = {} } = isOptions(sizeOrOptions) ? sizeOrOptions : { size: sizeOrOptions, mode: modeArgument }; + static async renderSVG(svg: string | BinaryData, options: SVGOptions = {}): Promise { + checkOptions(options, 'renderSVG(svg, { size: 2, mode: \'width\' })'); + const { size = 1, mode = SVGScaleModes.SCALE, fonts = [], images: givenImages = {} } = options; if (mode !== 'scale' && mode !== 'width' && mode !== 'height') throw new Error('Invalid SVG scaling mode'); diff --git a/tests/options-only.js b/tests/options-only.js index 6071aa9d..fbb7fa8a 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -1,4 +1,4 @@ -const {Image, Frame, Animation, GainMap, ResizeModes} = require('..'); +const {Image, Frame, Animation, GIF, GainMap, ResizeModes} = require('..'); const panic = message => { console.error(message); @@ -97,6 +97,20 @@ const sameImage = (image, reference, label) => { if (!(await photo.encodeJPEG({quality: 80})).length || !(await makeAnimation().encodeGIF({quality: 10})).length) panic('encoders with options'); if (!(await gainMap.encode({quality: 70})).length || !(await gainMap.encode()).length) panic('GainMap.encode with options'); + // renderSVG takes its size and mode in the options object only + const svgSource = ''; + for (const [label, call] of Object.entries({ + 'renderSVG(svg, 2)': () => Image.renderSVG(svgSource, 2), + 'renderSVG(svg, 2, width)': () => Image.renderSVG(svgSource, 2, 'width'), + 'renderSVG(svg, width)': () => Image.renderSVG(svgSource, 'width') + })) { + const error = await rejects(call, label); + if (!/renderSVG\(svg, \{/.test(error.message)) panic(`${label}: the message should show the options form, got ${error.message}`); + } + if ((await Image.renderSVG(svgSource, {size: 2})).width !== 20) panic('renderSVG(svg, { size }) should scale'); + const byWidth = await Image.renderSVG(svgSource, {size: 30, mode: 'width'}); + if (byWidth.width !== 30 || byWidth.height !== 30) panic(`renderSVG(svg, { size, mode: 'width' }): ${byWidth.width}x${byWidth.height}`); + // the options form still resizes; undefined stays fine if (photo.clone().resize(10, 6, {mode: ResizeModes.AREA}).width !== 10) panic('resize(10, 6, { mode })'); if (photo.clone().resize(10, 6).height !== 6) panic('resize(10, 6)'); diff --git a/tests/options.js b/tests/options.js index 7d02e0d7..59d6fb52 100644 --- a/tests/options.js +++ b/tests/options.js @@ -1,5 +1,5 @@ const fs = require('fs').promises; -const {Image, Frame, Animation, GIF, decode, ResizeModes, SVGScaleModes} = require('..'); +const {Image, Frame, Animation, GIF, decode, ResizeModes} = require('..'); const panic = message => { console.error(message); @@ -33,7 +33,6 @@ const same = (name, a, b) => { if (!Buffer.from(await tile()[method]({metadata: {author}})).includes(author)) panic(`${method}: metadata missing`); const svg = await fs.readFile('./tests/svgs/crown.svg'); - same('renderSVG', await Image.renderSVG(svg, 40, SVGScaleModes.WIDTH), await Image.renderSVG(svg, {size: 40, mode: SVGScaleModes.WIDTH})); // frames and animations const a = new Frame(8, 6, 40, 2, 3, 'background'), b = new Frame(8, 6, {duration: 40, xOffset: 2, yOffset: 3, disposalMode: 'background'}); diff --git a/tests/regressions.js b/tests/regressions.js index a6ddfdad..daa6ef03 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -1081,7 +1081,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); }; for (const [name, content] of Object.entries(svgs)) { const start = Date.now(); - await Image.renderSVG(`${content}`, 1).catch(() => {}); + await Image.renderSVG(`${content}`, {size: 1}).catch(() => {}); if (Date.now() - start > 1000) panic(`SVG ${name}: took ${Date.now() - start} ms`); } } diff --git a/tests/svg.js b/tests/svg.js index b2941b47..51aa15ca 100644 --- a/tests/svg.js +++ b/tests/svg.js @@ -10,7 +10,7 @@ const panic = message => { (async () => { { const svg = await fs.readFile('./tests/svgs/potato.svg'); - const image = await Image.renderSVG(svg.toString(), 256 / 36, SVGScaleModes.SCALE); + const image = await Image.renderSVG(svg.toString(), {size: 256 / 36, mode: SVGScaleModes.SCALE}); const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) @@ -23,7 +23,7 @@ const panic = message => { { // gradients, clip paths, masks, style sheets, symbols, strokes and fill rules const svg = await fs.readFile('./tests/svgs/features.svg'); - const image = await Image.renderSVG(svg, 480, SVGScaleModes.WIDTH); + const image = await Image.renderSVG(svg, {size: 480, mode: SVGScaleModes.WIDTH}); if (image.width !== 480 || image.height !== 320) panic('features: unexpected size'); const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); From c53975959ee686b67aa6afaf29c50bea9d9b0349 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 17:00:23 +0000 Subject: [PATCH 015/188] remove the positional arguments of Frame and Animation --- CHANGELOG.md | 1 + src/animation.ts | 68 +++++++++++++++---------------------- src/frame.ts | 33 ++++-------------- src/gif.ts | 4 +-- tests/animation-encoder.js | 2 +- tests/animation-optimize.js | 40 +++++++++++----------- tests/animation.js | 16 ++++----- tests/edge/smoke.mjs | 2 +- tests/gif.js | 8 ++--- tests/metadata.js | 2 +- tests/misc.js | 10 +++--- tests/options-only.js | 22 ++++++++++++ tests/options.js | 9 +++-- tests/png.js | 2 +- tests/regressions.js | 32 ++++++++--------- tests/trim.js | 2 +- tests/webp-lossy.js | 4 +-- 17 files changed, 122 insertions(+), 135 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ca52096..84e0a5eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ - **The positional arguments of `Image.renderSVG()` are removed**: `Image.renderSVG(svg, size, mode)` is `Image.renderSVG(svg, { size, mode })`, which takes the same `fonts` and `images` as before: `Image.renderSVG(svg, 2, 'width')` is now `Image.renderSVG(svg, { size: 2, mode: 'width' })`. +- **The positional arguments of `new Frame()`, `Frame.from()` and `new Animation()` are removed, and `Animation.decode()` takes no flag**: `new Frame(width, height, duration, xOffset, yOffset, disposalMode)` is `new Frame(width, height, { duration, xOffset, yOffset, disposalMode })`, `Frame.from(image, duration, xOffset, yOffset, disposalMode)` is `Frame.from(image, { duration, xOffset, yOffset, disposalMode })`, `new Animation(frames, loopCount)` is `new Animation(frames, { loopCount })`, and `Animation.decode(data, true)` is `Animation.decode(data, { onlyFirstFrame: true })`. The `GIF` class keeps its arguments until it is removed. ## 3.20.1 diff --git a/src/animation.ts b/src/animation.ts index 87065804..1b83928a 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -12,7 +12,7 @@ import { Frame, FrameDisposalModes } from './frame.ts'; import { ImageType } from './image-type.ts'; import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts'; import { Metadata, resolveMetadata, pngText, exifData, gifComments } from './metadata.ts'; -import { isOptions, checkOptions, finite, compressionLevel } from './util/options.ts'; +import { checkOptions, finite, compressionLevel } from './util/options.ts'; import { MAX_PIXELS, decoding } from './util/limits.ts'; import { AnimationEncoder } from './animation-encoder.ts'; @@ -23,7 +23,7 @@ const fromPlays = (plays: number): number => plays === 0 ? -1 : plays - 1; // frames decoded fully composited, as full canvas frames function canvasFrames(width: number, height: number, frames: Array<{ pixels: Uint8Array | Uint8ClampedArray, duration: number }>): Frame[] { return frames.map(({ pixels, duration }) => { - const frame = new Frame(width, height, duration, 0, 0, FrameDisposalModes.BACKGROUND); + const frame = new Frame(width, height, { duration, disposalMode: FrameDisposalModes.BACKGROUND }); // the composited frames are copies already, the frame can keep them frame.bitmap = pixels; return frame; @@ -132,16 +132,10 @@ export class Animation extends Array { * @param options See {@link AnimationOptions} */ constructor(frames: Frame[], options?: AnimationOptions); - /** - * Creates a new animation - * @param frames The frames to create the animation from - * @param loopCount How many times to repeat the animation (`Infinity` = forever, 0 = play once, 1 = play twice, ...) - * @deprecated Pass {@link AnimationOptions} instead, the positional arguments will be removed in 4.0 - */ - constructor(frames: Frame[], loopCount?: number); - constructor(frames: Frame[], loopCountOrOptions: number | AnimationOptions = -1) { + constructor(frames: Frame[], options: AnimationOptions = {}) { super(); - const { loopCount = -1 } = isOptions(loopCountOrOptions) ? loopCountOrOptions : { loopCount: loopCountOrOptions }; + checkOptions(options, 'Animation(frames, { loopCount: 2 })'); + const { loopCount = -1 } = options; // push instead of super(...frames), so a single frame isn't taken as a length for (const frame of frames) this.push(frame); @@ -215,13 +209,13 @@ export class Animation extends Array { * (same semantics as Array.prototype.slice) * @param start The index of the first frame (negative counts from the end). Default: `0` * @param end The index after the last frame (negative counts from the end). Default: the frame count - * @deprecated Use `new Animation(animation.frames.slice(start, end), animation.loopCount)`: in 4.0 animations + * @deprecated Use `new Animation(animation.frames.slice(start, end), { loopCount: animation.loopCount })`: in 4.0 animations * aren't arrays anymore * @group Deprecated */ override slice(start?: number, end?: number): this { - const Class = this.constructor as new (frames: Frame[], loopCount: number) => this; - return new Class(Array.prototype.slice.call(this, start, end) as Frame[], this.loopCount); + const Class = this.constructor as new (frames: Frame[], options: AnimationOptions) => this; + return new Class(Array.prototype.slice.call(this, start, end) as Frame[], { loopCount: this.loopCount }); } /** @@ -429,31 +423,23 @@ export class Animation extends Array { * @param options See {@link AnimationDecodeOptions} */ static decode(input: BinaryData, options?: AnimationDecodeOptions): Promise; - /** - * Decodes an animated GIF, APNG, WebP or JPEG XL into fully composited frames (still images become a single frame) - * @param input The binary data to decode - * @param onlyExtractFirstFrame Whether to stop decoding after the first frame - * @deprecated Pass {@link AnimationDecodeOptions} instead, the positional arguments will be removed in 4.0 - */ - static decode(input: BinaryData, onlyExtractFirstFrame?: boolean): Promise; - static async decode(input: BinaryData, onlyFirstFrameOrOptions: boolean | AnimationDecodeOptions = false): Promise { + static async decode(input: BinaryData, options: AnimationDecodeOptions = {}): Promise { // (checked outside of decoding(), which reports RangeErrors as damaged data) - if (isOptions(onlyFirstFrameOrOptions)) { - const { maxPixels = MAX_PIXELS, threads = 1 } = onlyFirstFrameOrOptions; - if (!Number.isInteger(threads) || threads < 1) throw new RangeError(`threads must be a positive integer, not ${threads}`); - if (!(Number(maxPixels) > 0)) throw new RangeError(`maxPixels must be a positive number, not ${maxPixels}`); - const { toneMapping = 'reinhard' } = onlyFirstFrameOrOptions; - if (toneMapping !== 'reinhard' && toneMapping !== 'clamp') throw new TypeError(`toneMapping must be 'reinhard' or 'clamp', not ${String(toneMapping)}`); - } - return decoding(() => Animation.__decode__(input, onlyFirstFrameOrOptions)); + checkOptions(options, 'Animation.decode(data, { onlyFirstFrame: true })'); + const { maxPixels = MAX_PIXELS, threads = 1 } = options; + if (!Number.isInteger(threads) || threads < 1) throw new RangeError(`threads must be a positive integer, not ${threads}`); + if (!(Number(maxPixels) > 0)) throw new RangeError(`maxPixels must be a positive number, not ${maxPixels}`); + const { toneMapping = 'reinhard' } = options; + if (toneMapping !== 'reinhard' && toneMapping !== 'clamp') throw new TypeError(`toneMapping must be 'reinhard' or 'clamp', not ${String(toneMapping)}`); + return decoding(() => Animation.__decode__(input, options)); } /** * @internal * @ignore */ - private static async __decode__(input: BinaryData, onlyFirstFrameOrOptions: boolean | AnimationDecodeOptions): Promise { - const { onlyFirstFrame: onlyExtractFirstFrame = false, maxPixels = MAX_PIXELS, applyColorProfile = true, threads = Math.max(1, Math.min(availableThreads(), 8)), applyOrientation = true, toneMapping = 'reinhard', gainMap = true } = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions : { onlyFirstFrame: onlyFirstFrameOrOptions }; + private static async __decode__(input: BinaryData, options: AnimationDecodeOptions): Promise { + const { onlyFirstFrame: onlyExtractFirstFrame = false, maxPixels = MAX_PIXELS, applyColorProfile = true, threads = Math.max(1, Math.min(availableThreads(), 8)), applyOrientation = true, toneMapping = 'reinhard', gainMap = true } = options; const orient = (frames: Frame[], orientation: number): Frame[] => { if (applyOrientation && orientation > 1) for (const frame of frames) frame.__orient__(orientation); return frames; @@ -471,18 +457,18 @@ export class Animation extends Array { if (type === 'webp') { const decoded = webp.decodeAnimation(data, onlyExtractFirstFrame, maxPixels); const frames = orient(profile(canvasFrames(decoded.width, decoded.height, webp.composite(decoded)), decoded.icc), decoded.orientation); - return new Animation(frames, fromPlays(decoded.loopCount)); + return new Animation(frames, { loopCount: fromPlays(decoded.loopCount) }); } // (still PNGs decode like images) if (type === 'png' && !png.isAnimated(data)) { const image = await Image.decode(data, { maxPixels, applyOrientation, applyColorProfile }); - return new Animation(canvasFrames(image.width, image.height, [{ pixels: image.bitmap, duration: 0 }]), -1); + return new Animation(canvasFrames(image.width, image.height, [{ pixels: image.bitmap, duration: 0 }]), { loopCount: -1 }); } if (type === 'png') { const decoded = await png.decodeAnimation(data, onlyExtractFirstFrame, maxPixels); const frames = orient(profile(canvasFrames(decoded.width, decoded.height, png.compositeAnimation(decoded)), decoded.icc), decoded.orientation); - return new Animation(frames, decoded.frames.length > 1 ? fromPlays(decoded.plays) : -1); + return new Animation(frames, { loopCount: decoded.frames.length > 1 ? fromPlays(decoded.plays) : -1 }); } if (type === 'jxl') { @@ -490,7 +476,7 @@ export class Animation extends Array { let loopCount = -1, icc: Uint8Array | undefined, space: jxl.ColorSpace | undefined; let orientation = 1; for await (const decoded of jxl.frames(data, maxPixels, toneMapping, threads)) { - const frame = new Frame(decoded.width, decoded.height, decoded.duration, 0, 0, FrameDisposalModes.BACKGROUND); + const frame = new Frame(decoded.width, decoded.height, { duration: decoded.duration, disposalMode: FrameDisposalModes.BACKGROUND }); frame.bitmap = decoded.pixels; frames.push(frame); orientation = decoded.orientation; @@ -515,10 +501,10 @@ export class Animation extends Array { frame.gainMap = null; } } - return new Animation(frames, frames.length > 1 ? loopCount : -1); + return new Animation(frames, { loopCount: frames.length > 1 ? loopCount : -1 }); } - if (type) return new Animation([Frame.from(await Image.decode(data, { maxPixels, applyOrientation, applyColorProfile, toneMapping, gainMap, threads }), 0)]); + if (type) return new Animation([Frame.from(await Image.decode(data, { maxPixels, applyOrientation, applyColorProfile, toneMapping, gainMap, threads }), { duration: 0 })]); throw new Error('Unsupported image type'); } @@ -526,7 +512,7 @@ export class Animation extends Array { * @internal * @ignore */ - static __fromGIF__(data: Uint8Array, onlyExtractFirstFrame: boolean, Class: new (frames: Frame[], loopCount: number) => T, maxPixels = MAX_PIXELS): Promise { + static __fromGIF__(data: Uint8Array, onlyExtractFirstFrame: boolean, Class: new (frames: Frame[], options: AnimationOptions) => T, maxPixels = MAX_PIXELS): Promise { return decoding(() => Animation.__gifFrames__(data, onlyExtractFirstFrame, Class, maxPixels)); } @@ -534,9 +520,9 @@ export class Animation extends Array { * @internal * @ignore */ - private static __gifFrames__(data: Uint8Array, onlyExtractFirstFrame: boolean, Class: new (frames: Frame[], loopCount: number) => T, maxPixels: number): T { + private static __gifFrames__(data: Uint8Array, onlyExtractFirstFrame: boolean, Class: new (frames: Frame[], options: AnimationOptions) => T, maxPixels: number): T { const decoded = gifCodec.decode(data, onlyExtractFirstFrame, maxPixels); const frames = gifCodec.composite(decoded).map(({ pixels, delay }) => ({ pixels, duration: 10 * delay })); - return new Class(canvasFrames(decoded.width, decoded.height, frames), decoded.loopCount); + return new Class(canvasFrames(decoded.width, decoded.height, frames), { loopCount: decoded.loopCount }); } } diff --git a/src/frame.ts b/src/frame.ts index d2288a11..0d4e771a 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -1,5 +1,5 @@ import { Image, ResizeOptions, ResizeToOptions, TrimOptions, resizeArguments } from './image.ts'; -import { isOptions, checkOptions, finite } from './util/options.ts'; +import { checkOptions, finite } from './util/options.ts'; /** Names of the frame disposal modes, see {@link Frame.disposalMode} */ export type FrameDisposalModeName = 'any' | 'keep' | 'previous' | 'background'; @@ -77,20 +77,9 @@ export class Frame extends Image { * @param options See {@link FrameOptions} */ constructor(width: number, height: number, options?: FrameOptions); - /** - * Creates a new, blank frame - * @param width The width in pixels - * @param height The height in pixels - * @param duration The frame's duration (in ms) - * @param xOffset The frame's offset on the x-axis - * @param yOffset The frame's offset on the y-axis - * @param disposalMode The frame's disposal mode - * @deprecated Pass {@link FrameOptions} instead, the positional arguments will be removed in 4.0 - */ - constructor(width: number, height: number, duration?: number, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName); - constructor(width: number, height: number, durationOrOptions?: number | FrameOptions, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName) { - const { duration = 100, xOffset: x = 0, yOffset: y = 0, disposalMode: disposal = FrameDisposalModes.KEEP } = - isOptions(durationOrOptions) ? durationOrOptions : { duration: durationOrOptions, xOffset, yOffset, disposalMode }; + constructor(width: number, height: number, options: FrameOptions = {}) { + checkOptions(options, 'Frame(width, height, { duration: 100 })'); + const { duration = 100, xOffset: x = 0, yOffset: y = 0, disposalMode: disposal = FrameDisposalModes.KEEP } = options; if (finite(duration, 'frame duration') < 0) throw new RangeError('Invalid frame duration'); finite(x, 'x offset'); @@ -136,21 +125,11 @@ export class Frame extends Image { * @param options See {@link FrameOptions} */ static from(image: Image, options?: FrameOptions): Frame; - /** - * Converts an Image instance to a Frame, cloning it in the process - * @param image The image to create the frame from - * @param duration The frame's duration (in ms) - * @param xOffset The frame's offset on the x-axis - * @param yOffset The frame's offset on the y-axis - * @param disposalMode The frame's disposal mode - * @deprecated Pass {@link FrameOptions} instead, the positional arguments will be removed in 4.0 - */ - static from(image: Image, duration?: number, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName): Frame; - static from(image: Image, durationOrOptions?: number | FrameOptions, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName): Frame { + static from(image: Image, options: FrameOptions = {}): Frame { if (!(image instanceof Image)) throw new TypeError('Invalid image passed'); + checkOptions(options, 'Frame.from(image, { duration: 100 })'); - const options = isOptions(durationOrOptions) ? durationOrOptions : { duration: durationOrOptions, xOffset, yOffset, disposalMode }; const frame = new Frame(image.width, image.height, options); frame.bitmap.set(image.bitmap); diff --git a/src/gif.ts b/src/gif.ts index 9eb1a489..0b667bb6 100644 --- a/src/gif.ts +++ b/src/gif.ts @@ -7,7 +7,7 @@ import { isOptions } from './util/options.ts'; /** * Represents a GIF image as an array of frames, an {@link Animation} whose `encode()` and `decode()` work with GIFs. * Array methods like map and filter return plain arrays. - * @deprecated Use {@link Animation}, which works the same and also handles APNG: `new Animation(frames, loopCount)`, + * @deprecated Use {@link Animation}, which works the same and also handles APNG: `new Animation(frames, { loopCount })`, * {@link Animation.decode} and {@link Animation.encodeGIF}. `GIF` will be removed in 4.0 * @group Deprecated */ @@ -19,7 +19,7 @@ export class GIF extends Animation { * {@link AnimationOptions} */ constructor(frames: Frame[], loopCountOrOptions?: number | AnimationOptions) { - super(frames, loopCountOrOptions as number); + super(frames, isOptions(loopCountOrOptions) || loopCountOrOptions === undefined ? loopCountOrOptions : { loopCount: loopCountOrOptions }); } /** A short description for debugging, e.g. `GIF<64x64x1000ms>` */ diff --git a/tests/animation-encoder.js b/tests/animation-encoder.js index 3a14e8e5..a61403ff 100644 --- a/tests/animation-encoder.js +++ b/tests/animation-encoder.js @@ -18,7 +18,7 @@ const metadata = {creationTime: 0, software: ''}; frames.push(f); } // offsets, every disposal, translucent pixels; a decoded GIF - const synthetic = new Animation([new Frame(40, 30, {duration: 20}).fill(0x203040ff), ...frames], 2); + const synthetic = new Animation([new Frame(40, 30, {duration: 20}).fill(0x203040ff), ...frames], {loopCount: 2}); const decoded = await Animation.decode(await fs.readFile('./tests/targets/gif.gif')); for (const [name, animation] of [['synthetic', synthetic], ['gif.gif', decoded]]) diff --git a/tests/animation-optimize.js b/tests/animation-optimize.js index b041fd4b..3d566f03 100644 --- a/tests/animation-optimize.js +++ b/tests/animation-optimize.js @@ -26,7 +26,7 @@ async function composite(animation) { const {width, height} = animation; const canvases = png.compositeAnimation({width, height, frames: animation.__apngFrames__()}); return new Animation(canvases.map(({pixels, duration}) => { - const frame = new Frame(width, height, duration); + const frame = new Frame(width, height, {duration}); frame.bitmap.set(pixels); return frame; })); @@ -71,34 +71,34 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { (async () => { // a sticker: an antialiased disc moving over a transparent background (pixels become transparent again) await check('moving sticker', new Animation(Array.from({length: 8}, (_, i) => - Frame.from(new Image(48, 32).composite(disc(16, 0xff8800ff), {x: i * 4, y: 4 + (i & 1)}), 60)))); + Frame.from(new Image(48, 32).composite(disc(16, 0xff8800ff), {x: i * 4, y: 4 + (i & 1)}), {duration: 60})))); // fading out: pixels get more transparent await check('fade out', new Animation(Array.from({length: 6}, (_, i) => - Frame.from(new Image(20, 20).drawBox(4, 4, 12, 12, 0x3366ff00 | 255 - i * 50), 100)))); + Frame.from(new Image(20, 20).drawBox(4, 4, 12, 12, 0x3366ff00 | 255 - i * 50), {duration: 100})))); // frames with offsets and disposal modes, odd offsets (WebP stores them halved) await check('offsets and disposal', new Animation([ - Frame.from(new Image(30, 30).fill(0x202020ff), 100), - new Frame(5, 7, 100, 3, 9, 'previous').fill(0xff0000ff), - new Frame(9, 3, 100, 11, 1, 'background').fill(0x00ff0080), - new Frame(4, 4, 100, 21, 21, 'keep').fill(0x0000ffff), - new Frame(30, 30, 100, 0, 0, 'background').fill(0), - new Frame(1, 1, 100, 29, 29).fill(0xffffffff) + Frame.from(new Image(30, 30).fill(0x202020ff), {duration: 100}), + new Frame(5, 7, {duration: 100, xOffset: 3, yOffset: 9, disposalMode: 'previous'}).fill(0xff0000ff), + new Frame(9, 3, {duration: 100, xOffset: 11, yOffset: 1, disposalMode: 'background'}).fill(0x00ff0080), + new Frame(4, 4, {duration: 100, xOffset: 21, yOffset: 21, disposalMode: 'keep'}).fill(0x0000ffff), + new Frame(30, 30, {duration: 100, xOffset: 0, yOffset: 0, disposalMode: 'background'}).fill(0), + new Frame(1, 1, {duration: 100, xOffset: 29, yOffset: 29}).fill(0xffffffff) ])); // a frame that only clears: GIF can't blend pixels to transparent, the frame before clears its area instead await check('cleared frame', new Animation([ - Frame.from(new Image(12, 12).drawBox(2, 2, 6, 6, 0xff0000ff), 100, 0, 0, 'background'), - new Frame(12, 12, 100), - Frame.from(new Image(12, 12).drawBox(4, 4, 6, 6, 0x00ff00ff), 100) + Frame.from(new Image(12, 12).drawBox(2, 2, 6, 6, 0xff0000ff), {duration: 100, xOffset: 0, yOffset: 0, disposalMode: 'background'}), + new Frame(12, 12, {duration: 100}), + Frame.from(new Image(12, 12).drawBox(4, 4, 6, 6, 0x00ff00ff), {duration: 100}) ])); // an empty first frame, and identical frames, which become one with their durations added up (the empty last // frame doesn't clear the canvas, so it shows the same too) { - const dot = Frame.from(new Image(10, 10).setPixelAt(5, 5, 0xffffffff), 100); - const animation = new Animation([new Frame(10, 10, 40), dot, dot.clone(), Frame.from(dot, 25), new Frame(10, 10, 30)]); + const dot = Frame.from(new Image(10, 10).setPixelAt(5, 5, 0xffffffff), {duration: 100}); + const animation = new Animation([new Frame(10, 10, {duration: 40}), dot, dot.clone(), Frame.from(dot, {duration: 25}), new Frame(10, 10, {duration: 30})]); await check('empty and identical frames', animation); const decoded = await Animation.decode(await animation.encodeAPNG()); if (decoded.length !== 2 || decoded[0].duration !== 40 || decoded[1].duration !== 255) panic(`identical frames: ${decoded.length} frames, durations ${Array.from(decoded, f => f.duration)}`); @@ -110,7 +110,7 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { const background = new Image(40, 20).fill((x, y) => Colors.fromRGBA(y * 6, 100, 200 - y * 4, 255)); const animation = new Animation(Array.from({length: 6}, (_, i) => Frame.from(background.clone() .drawBox(1, 1, 6, 6, Colors.fromRGBA(255, i * 40, 0, 255)) - .drawBox(32, 13, 6, 6, Colors.fromRGBA(0, 255, i * 40, 255)), 100))); + .drawBox(32, 13, 6, 6, Colors.fromRGBA(0, 255, i * 40, 255)), {duration: 100}))); await check('opaque animation', animation); // with transparency for free, the frames are sparse; with an alpha channel that costs as much as RGBA instead of RGB // (APNG), they're filled in @@ -129,7 +129,7 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { for (let j = 0, k = 0; j < 24; j++) for (let i = 0; i < 24; i++) blocks.push(ring(i, j) ? (k++ + shift) & 255 : (i * 7 + j * 13) & 255); return new Image(96, 96).fill((x, y) => color(blocks[(y >> 2) * 24 + (x >> 2)])); }; - const animation = new Animation([Frame.from(grid(0), 100), Frame.from(grid(1), 100)]); + const animation = new Animation([Frame.from(grid(0), {duration: 100}), Frame.from(grid(1), {duration: 100})]); const decoded = await Animation.decode(await animation.encodeGIF()); decoded.forEach((frame, i) => { if (frame.bitmap.some((v, k) => v !== animation[i].bitmap[k])) panic(`palette: GIF frame ${i} lost colors`); @@ -138,8 +138,8 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { // optimize: false stores the frames as they are: same animation, but no frames merged or cut { - const dot = Frame.from(new Image(10, 10).setPixelAt(5, 5, 0xffffffff), 100); - const animation = new Animation([new Frame(10, 10, 40), dot, dot.clone(), new Frame(4, 4, {duration: 30, xOffset: 3, yOffset: 1}).fill(0xff0000ff)]); + const dot = Frame.from(new Image(10, 10).setPixelAt(5, 5, 0xffffffff), {duration: 100}); + const animation = new Animation([new Frame(10, 10, {duration: 40}), dot, dot.clone(), new Frame(4, 4, {duration: 30, xOffset: 3, yOffset: 1}).fill(0xff0000ff)]); for (const format of ['GIF', 'APNG', 'WebP']) { const plain = await Animation.decode(await animation[`encode${format}`]({optimize: false})); if (plain.length !== 4) panic(`optimize false: ${format} has ${plain.length} frames instead of 4`); @@ -153,7 +153,7 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { { const background = new Image(160, 120).fill((x, y) => Colors.fromRGBA(x * 7 ^ y * 13, x * y, y * 5, 255)); const animation = new Animation(Array.from({length: 20}, (_, i) => - Frame.from(background.clone().drawBox(10 + i * 6, 50, 8, 8, 0xffffffff), 50))); + Frame.from(background.clone().drawBox(10 + i * 6, 50, 8, 8, 0xffffffff), {duration: 50}))); await check('screen recording', animation); const still = (await background.encodePNG()).length; for (const format of ['APNG', 'WebP', 'GIF']) { @@ -176,7 +176,7 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { const animation = new Animation(Array.from({length: 8}, (_, i) => { const layer = new Image(100, 60).fill(x => x < 80 ? Colors.fromRGBA(i % 4, 180 + i * 7 % 5, 250 + i * 3 % 4, 150) : 0); return Frame.from(map.clone().composite(layer).drawBox(5 + i * 10, 20, 12, 12, 0xff0000ff) - .drawBox(90, 50, 6, 6, Colors.fromRGBA(0, i * 30, 0, 255)), 50); + .drawBox(90, 50, 6, 6, Colors.fromRGBA(0, i * 30, 0, 255)), {duration: 50}); })); const tolerance = 3; diff --git a/tests/animation.js b/tests/animation.js index 9220701c..528411f6 100644 --- a/tests/animation.js +++ b/tests/animation.js @@ -24,11 +24,11 @@ const square = (color, w, h) => new Image(w, h).fill((x, y) => (x + y) % 5 === 0 (async () => { for (const variant of [0, 1]) { const animation = new Animation([ - Frame.from(new Image(40, 30).fill((x, y) => Colors.fromRGBA(x * 6, y * 8, 128, 255)), 100), - Frame.from(square(0xff2040ff, 10, 8), 50, variant ? 3 : 4, variant ? 5 : 6, 'background'), - Frame.from(square(0x20ff40ff, 12, 10), 70, 10, 10, variant ? 'previous' : 'keep'), - Frame.from(square(0xc8374fff, 8, 8), 30, 20, 2, 'keep') - ], 2); + Frame.from(new Image(40, 30).fill((x, y) => Colors.fromRGBA(x * 6, y * 8, 128, 255)), {duration: 100}), + Frame.from(square(0xff2040ff, 10, 8), {duration: 50, xOffset: variant ? 3 : 4, yOffset: variant ? 5 : 6, disposalMode: 'background'}), + Frame.from(square(0x20ff40ff, 12, 10), {duration: 70, xOffset: 10, yOffset: 10, disposalMode: variant ? 'previous' : 'keep'}), + Frame.from(square(0xc8374fff, 8, 8), {duration: 30, xOffset: 20, yOffset: 2, disposalMode: 'keep'}) + ], {loopCount: 2}); const reference = expected(animation); for (const format of ['APNG', 'WebP']) { @@ -43,7 +43,7 @@ const square = (color, w, h) => new Image(w, h).fill((x, y) => (x + y) % 5 === 0 } // a plain PNG's default image is what Image.decode returns for an APNG - const animation = new Animation([Frame.from(new Image(8, 8).fill(0xff0000ff), 100), Frame.from(new Image(8, 8).fill(0x00ff00ff), 100)]); + const animation = new Animation([Frame.from(new Image(8, 8).fill(0xff0000ff), {duration: 100}), Frame.from(new Image(8, 8).fill(0x00ff00ff), {duration: 100})]); const apng = await animation.encodeAPNG(); const still = await Image.decode(apng); if (still.getPixelAt(0, 0) !== 0xff0000ff) panic('APNG: the default image should be the first frame'); @@ -59,7 +59,7 @@ const square = (color, w, h) => new Image(w, h).fill((x, y) => (x + y) % 5 === 0 { const animation = new Animation([Frame.from(still)]); if (animation.frames.length !== 1 || animation.frames[0] !== animation[0]) panic('frames should hold the frames'); - animation.frames.push(Frame.from(still, 50)); + animation.frames.push(Frame.from(still, {duration: 50})); if (animation.length !== 2 || animation.duration !== 150) panic('frames.push should add a frame'); if (!Array.isArray(animation.frames.map(f => f.duration))) panic('frames.map should return an array'); animation.frames = animation.frames.filter(f => f.duration === 50); @@ -72,7 +72,7 @@ const square = (color, w, h) => new Image(w, h).fill((x, y) => (x + y) % 5 === 0 // loop counts: -1 forever, 0 once, n repeats for (const loopCount of [-1, 0, 5]) { // (different frames: identical ones are merged, and a single frame has no loop count) - const two = new Animation([Frame.from(still), Frame.from(still.clone().invert())], loopCount); + const two = new Animation([Frame.from(still), Frame.from(still.clone().invert())], {loopCount}); for (const format of ['APNG', 'WebP', 'GIF']) if ((await Animation.decode(await two[`encode${format}`]())).loopCount !== loopCount) panic(`${format}: loop count ${loopCount} not kept`); } diff --git a/tests/edge/smoke.mjs b/tests/edge/smoke.mjs index be3cd7db..4fd63262 100644 --- a/tests/edge/smoke.mjs +++ b/tests/edge/smoke.mjs @@ -28,7 +28,7 @@ export async function smoke() { await step('jxl lossless', async () => back(await alpha.encodeJXL())); await step('jxl lossy', async () => back(await alpha.encodeJXL({ quality: 85, effort: 2 }))); await step('tga, bmp, qoi, pnm', async () => (await Promise.all([photo.encodeTGA(), photo.encodeBMP(), photo.encodeQOI(), photo.encodePNM()].map(async d => back(await d)))).join(', ')); - const frames = [Frame.from(photo.clone().resize(64, 64), 100), Frame.from(photo.clone().resize(64, 64).invert(), 100)]; + const frames = [Frame.from(photo.clone().resize(64, 64), {duration: 100}), Frame.from(photo.clone().resize(64, 64).invert(), {duration: 100})]; for (const [name, encode] of [['gif', a => a.encodeGIF()], ['apng', a => a.encodeAPNG()], ['animated webp', a => a.encodeWebP({ quality: 70 })]]) { await step(name, async () => { const decoded = await Animation.decode(await encode(new Animation(frames))); diff --git a/tests/gif.js b/tests/gif.js index ecf7f910..611654be 100644 --- a/tests/gif.js +++ b/tests/gif.js @@ -57,13 +57,13 @@ const panic = message => { const black = Colors.fromRGBA(0, 0, 0, 255); const white = Colors.fromRGBA(255, 255, 255, 255); - const background = new Frame(4, 4, 100); + const background = new Frame(4, 4, {duration: 100}); background.fill(black); - const dot = new Frame(1, 1, 100, 1, 1, FrameDisposalModes.PREVIOUS); + const dot = new Frame(1, 1, {duration: 100, xOffset: 1, yOffset: 1, disposalMode: FrameDisposalModes.PREVIOUS}); dot.fill(white); - const hole = new Frame(2, 2, 100, 2, 2, FrameDisposalModes.BACKGROUND); + const hole = new Frame(2, 2, {duration: 100, xOffset: 2, yOffset: 2, disposalMode: FrameDisposalModes.BACKGROUND}); hole.fill((x, y) => x === y ? white : 0); - const last = new Frame(1, 1, 100, 0, 0); + const last = new Frame(1, 1, {duration: 100, xOffset: 0, yOffset: 0}); last.fill(white); const decoded = await GIF.decode(await new GIF([background, dot, hole, last], 2).encode()); diff --git a/tests/metadata.js b/tests/metadata.js index f392980a..4926bc4a 100644 --- a/tests/metadata.js +++ b/tests/metadata.js @@ -12,7 +12,7 @@ const latin1 = (data, text) => Buffer.from(data).includes(Buffer.from(text, 'lat const {VERSION} = await import('../dist/version.js'); const software = `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 animation = new Animation([Frame.from(image, {duration: 100}), Frame.from(new Image(8, 4).fill(0x00ff00ff), {duration: 100})]); const encoders = { png: m => image.encodePNG({metadata: m}), diff --git a/tests/misc.js b/tests/misc.js index c60586e1..f548e69a 100644 --- a/tests/misc.js +++ b/tests/misc.js @@ -174,18 +174,18 @@ if (Colors.fromHSLA(0, 0, 1, 0xff) !== 0xffffffff) } try { - new Frame(1, 1, -1); + new Frame(1, 1, {duration: -1}); panic('frame duration failed'); } catch { } -if (new Frame(1, 2, 3).toString() !== 'Frame<1x2x3ms>') panic('frame toString failed'); +if (new Frame(1, 2, {duration: 3}).toString() !== 'Frame<1x2x3ms>') panic('frame toString failed'); { const image = new Image(512, 512); - Frame.from(image, 10); + Frame.from(image, {duration: 10}); try { - Frame.from({}, 10); + Frame.from({}, {duration: 10}); panic('frame instanceof image failed'); } catch { } @@ -198,7 +198,7 @@ try { } { - const frame = new Frame(512, 128, 123); + const frame = new Frame(512, 128, {duration: 123}); const gif = new GIF([frame]); if (gif.toString() !== 'GIF<512x128x123ms>') panic('gif tostring failed'); if (gif.duration !== 123) panic('gif duration failed'); diff --git a/tests/options-only.js b/tests/options-only.js index fbb7fa8a..bd460e06 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -111,6 +111,28 @@ const sameImage = (image, reference, label) => { const byWidth = await Image.renderSVG(svgSource, {size: 30, mode: 'width'}); if (byWidth.width !== 30 || byWidth.height !== 30) panic(`renderSVG(svg, { size, mode: 'width' }): ${byWidth.width}x${byWidth.height}`); + // frames and animations take their duration, offsets, disposal mode and loop count in the options object only + const frame = new Frame(4, 4, {duration: 40, xOffset: 2, yOffset: 3, disposalMode: 'background'}); + if (frame.duration !== 40 || frame.xOffset !== 2 || frame.yOffset !== 3 || frame.disposalMode !== 'background') panic('new Frame(width, height, options)'); + if (Frame.from(photo, {duration: 40, xOffset: 2}).xOffset !== 2 || Frame.from(photo).duration !== 100) panic('Frame.from(image, options)'); + if (new Animation([new Frame(4, 4)], {loopCount: 2}).loopCount !== 2 || new Animation([new Frame(4, 4)]).loopCount !== -1) panic('new Animation(frames, options)'); + const encodedGIF = await new Animation([new Frame(4, 4)]).encodeGIF(); + if ((await Animation.decode(encodedGIF, {onlyFirstFrame: true})).length !== 1) panic('Animation.decode(data, { onlyFirstFrame })'); + if (new GIF([new Frame(4, 4)], 2).loopCount !== 2) panic('GIF keeps its 3.x loop count argument'); + const oldFrames = { + 'new Frame(4, 4, 100)': () => new Frame(4, 4, 100), + 'new Frame(4, 4, 100, 1, 1, "keep")': () => new Frame(4, 4, 100, 1, 1, 'keep'), + 'Frame.from(photo, 100)': () => Frame.from(photo, 100), + 'Frame.from(photo, 100, 1, 1, "keep")': () => Frame.from(photo, 100, 1, 1, 'keep'), + 'new Animation([frame], 2)': () => new Animation([frame], 2), + 'new Animation([frame], Infinity)': () => new Animation([frame], Infinity), + 'Animation.decode(gif, true)': () => Animation.decode(encodedGIF, true) + }; + for (const [label, call] of Object.entries(oldFrames)) { + const error = await rejects(call, label); + if (!/takes an options object/.test(error.message)) panic(`${label}: the message should name the options form, got ${error.message}`); + } + // the options form still resizes; undefined stays fine if (photo.clone().resize(10, 6, {mode: ResizeModes.AREA}).width !== 10) panic('resize(10, 6, { mode })'); if (photo.clone().resize(10, 6).height !== 6) panic('resize(10, 6)'); diff --git a/tests/options.js b/tests/options.js index 59d6fb52..d580e0ef 100644 --- a/tests/options.js +++ b/tests/options.js @@ -1,5 +1,5 @@ const fs = require('fs').promises; -const {Image, Frame, Animation, GIF, decode, ResizeModes} = require('..'); +const {Image, Frame, Animation, GIF, decode} = require('..'); const panic = message => { console.error(message); @@ -8,10 +8,10 @@ const panic = message => { const same = (name, a, b) => { const x = a.bitmap ?? a, y = b.bitmap ?? b; - if (x.length !== y.length || x.some((v, i) => v !== y[i])) panic(`${name}: options object and positional arguments differ`); + if (x.length !== y.length || x.some((v, i) => v !== y[i])) panic(`${name}: the results differ`); }; -// every method with optional arguments takes them as an options object too, with the same result +// the defaults: leaving out the options and passing an empty object give the same result (async () => { const photo = await Image.decode(await fs.readFile('./tests/targets/external.jpg')); const tile = () => photo.clone().resize(64, 48); @@ -35,12 +35,11 @@ const same = (name, a, b) => { const svg = await fs.readFile('./tests/svgs/crown.svg'); // frames and animations - const a = new Frame(8, 6, 40, 2, 3, 'background'), b = new Frame(8, 6, {duration: 40, xOffset: 2, yOffset: 3, disposalMode: 'background'}); + const b = new Frame(8, 6, {duration: 40, xOffset: 2, yOffset: 3, disposalMode: 'background'}); const c = Frame.from(logo, {duration: 40, xOffset: 2, yOffset: 3, disposalMode: 'background'}); for (const frame of [b, c]) if (frame.duration !== 40 || frame.xOffset !== 2 || frame.yOffset !== 3 || frame.disposalMode !== 'background') panic('Frame: options not applied'); if (new Frame(1, 1, {}).duration !== 100 || new Frame(1, 1, {}).disposalMode !== 'keep') panic('Frame: wrong defaults'); - same('Frame', a, b); const frames = () => [Frame.from(tile(), {duration: 50}), Frame.from(tile().invert(), {duration: 50})]; if (new Animation(frames(), {loopCount: 2}).loopCount !== 2 || new GIF(frames(), {loopCount: 2}).loopCount !== 2) panic('Animation: loopCount not applied'); diff --git a/tests/png.js b/tests/png.js index bfd8f8a2..8546cf4f 100644 --- a/tests/png.js +++ b/tests/png.js @@ -66,7 +66,7 @@ const expected = { // browsers do (here a wrong sequence number in the second fcTL) { const frames = [100, 70000, 100].map((duration, i) => { - const frame = new Frame(8, 8, duration); + const frame = new Frame(8, 8, {duration}); frame.fill(0xff0000ff + i * 0x1000); return frame; }); diff --git a/tests/regressions.js b/tests/regressions.js index daa6ef03..578d190c 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -89,7 +89,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // frames keep their metadata { - const frame = new Frame(10, 10, 500, 2, 4, FrameDisposalModes.BACKGROUND); + const frame = new Frame(10, 10, {duration: 500, xOffset: 2, yOffset: 4, disposalMode: FrameDisposalModes.BACKGROUND}); const clone = frame.clone(); assert(clone instanceof Frame && clone.duration === 500 && clone.xOffset === 2 && clone.disposalMode === 'background', 'Frame.clone lost metadata'); @@ -108,7 +108,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // resizing GIFs scales partial frames proportionally { - const gif = new GIF([new Frame(10, 10), new Frame(4, 4, 100, 6, 6)]).resize(5, 5); + const gif = new GIF([new Frame(10, 10), new Frame(4, 4, {duration: 100, xOffset: 6, yOffset: 6})]).resize(5, 5); assert(gif.width === 5 && gif.height === 5, `GIF.resize: canvas is ${gif.width}x${gif.height}`); assert(gif[1].width === 2 && gif[1].xOffset === 3, 'GIF.resize: partial frame not scaled'); } @@ -327,7 +327,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // gif frame durations are rounded to centiseconds { // (different frames: identical ones are merged) - const gif = new GIF([new Frame(2, 2, 16), new Frame(2, 2, 14).fill(0xff0000ff)]); + const gif = new GIF([new Frame(2, 2, {duration: 16}), new Frame(2, 2, {duration: 14}).fill(0xff0000ff)]); const decoded = await GIF.decode(await gif.encode()); assert(decoded[0].duration === 20 && decoded[1].duration === 10, `gif durations should round, got ${decoded[0].duration}, ${decoded[1].duration}`); } @@ -367,13 +367,13 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // disposal modes read back as the names they are set with { - const frame = new Frame(1, 1, 100, 0, 0, FrameDisposalModes.PREVIOUS); + const frame = new Frame(1, 1, {duration: 100, xOffset: 0, yOffset: 0, disposalMode: FrameDisposalModes.PREVIOUS}); assert(frame.disposalMode === 'previous', 'disposalMode should be a name, got ' + frame.disposalMode); // the numeric ids (0-3) are not accepted any more, the names are let threw = false; try { frame.disposalMode = 0; } catch (e) { threw = e instanceof RangeError; } assert(threw && frame.disposalMode === 'previous', 'disposalMode should only take names'); - const decoded = await GIF.decode(await new GIF([new Frame(2, 2, 100, 0, 0, 'background'), new Frame(2, 2).fill(0xff0000ff)]).encode()); + const decoded = await GIF.decode(await new GIF([new Frame(2, 2, {duration: 100, xOffset: 0, yOffset: 0, disposalMode: 'background'}), new Frame(2, 2).fill(0xff0000ff)]).encode()); assert(decoded.length === 2, 'gif with disposal modes should roundtrip'); } @@ -407,12 +407,12 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); try { photo.clone().resize({}); } catch (e) { threw = e instanceof TypeError; } assert(threw, 'resize({}) throws'); - const frame = Frame.from(photo.clone(), 100, 4, 2); + const frame = Frame.from(photo.clone(), {duration: 100, xOffset: 4, yOffset: 2}); frame.resize({width: 6}); assert(frame.width === 6 && frame.height === 4 && frame.xOffset === 2 && frame.yOffset === 1, `Frame.resize({width}): ${frame.width}x${frame.height}+${frame.xOffset}+${frame.yOffset}`); - const animation = new Animation([Frame.from(photo.clone(), 100), Frame.from(photo.clone().invert(), 100)]); - const other = new Animation([Frame.from(photo.clone(), 100), Frame.from(photo.clone().invert(), 100)]); + const animation = new Animation([Frame.from(photo.clone(), {duration: 100}), Frame.from(photo.clone().invert(), {duration: 100})]); + const other = new Animation([Frame.from(photo.clone(), {duration: 100}), Frame.from(photo.clone().invert(), {duration: 100})]); animation.resize({height: 4}); other.resize(6, 4); assert(animation.width === 6 && animation.height === 4 && [...animation].every((f, i) => same(f, other[i])), 'Animation.resize({height})'); @@ -443,7 +443,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // the encoders take the options of the encode functions await withFrozenTime(async () => { const {Animation} = require('..'); - const frames = () => [Frame.from(new Image(6, 6).fill(0x336699ff), 100), Frame.from(new Image(6, 6).fill(0x996633ff), 100)]; + const frames = () => [Frame.from(new Image(6, 6).fill(0x336699ff), {duration: 100}), Frame.from(new Image(6, 6).fill(0x996633ff), {duration: 100})]; for (const [format, options] of [['GIF', {quality: 80, optimize: false}], ['APNG', {compression: 9}], ['WebP', {quality: 70, effort: 1}]]) { const animation = new Animation(frames(), {loopCount: 3}); const encoder = Animation[`${format.toLowerCase()}Encoder`](6, 6, {...options, loopCount: 3}); @@ -456,9 +456,9 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); await withFrozenTime(async () => { const {Animation} = require('..'); const still = new Image(6, 6).fill(0x336699ff); - const frames = () => [Frame.from(still.clone(), 100), Frame.from(still.clone().invert(), 100)]; + const frames = () => [Frame.from(still.clone(), {duration: 100}), Frame.from(still.clone().invert(), {duration: 100})]; const forever = new Animation(frames(), {loopCount: Infinity}), minusOne = new Animation(frames(), {loopCount: -1}); - assert(forever.loopCount === Infinity && new Animation(frames(), Infinity).loopCount === Infinity, 'Animation keeps Infinity'); + assert(forever.loopCount === Infinity && new Animation(frames(), {loopCount: Infinity}).loopCount === Infinity, 'Animation keeps Infinity'); for (const format of ['GIF', 'APNG', 'WebP']) { const a = await forever[`encode${format}`](), b = await minusOne[`encode${format}`](); assert(Buffer.from(a).equals(Buffer.from(b)), `${format}: Infinity vs -1`); @@ -483,10 +483,10 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const fs = require('fs'); const pixel = (image, x, y) => Array.from(image.bitmap.subarray((y * image.width + x) * 4, (y * image.width + x) * 4 + 4)); const tiff = orientation => Buffer.from([0x4d, 0x4d, 0, 42, 0, 0, 0, 8, 0, 1, 0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, orientation, 0, 0, 0, 0, 0, 0, 0, 0]); - const source = () => [0, 1].map(i => Frame.from(new Image(3, 2).fill((x, y) => Colors.fromRGBA(x * 100, y * 100, i * 120, 255)), 100)); + const source = () => [0, 1].map(i => Frame.from(new Image(3, 2).fill((x, y) => Colors.fromRGBA(x * 100, y * 100, i * 120, 255)), {duration: 100})); // APNG (eXIf) and animated WebP (EXIF): orientation 6 turns the top left into the top right - const png = Buffer.from(await new Animation(source(), 2).encodeAPNG({metadata: {software: ''}})); + const png = Buffer.from(await new Animation(source(), {loopCount: 2}).encodeAPNG({metadata: {software: ''}})); const chunk = (type, body) => { const out = Buffer.alloc(12 + body.length); out.writeUInt32BE(body.length); @@ -496,7 +496,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); return out; }; const withExif = Buffer.concat([png.subarray(0, png.length - 12), chunk('eXIf', tiff(6)), png.subarray(png.length - 12)]); - const webp = Buffer.from(await new Animation(source(), 2).encodeWebP({metadata: {software: ''}})); + const webp = Buffer.from(await new Animation(source(), {loopCount: 2}).encodeWebP({metadata: {software: ''}})); const exifChunk = Buffer.concat([Buffer.from('EXIF'), Buffer.from(new Uint8Array(new Uint32Array([tiff(6).length]).buffer)), tiff(6)]); // (the encoder's own EXIF chunk, with the creation time, becomes one decoders skip) const own = webp.indexOf('EXIF', 12, 'latin1'); @@ -563,7 +563,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const bordered = () => new Image(6, 5).fill(0x102030ff).setPixelAt(2, 2, 0xffffffff).setPixelAt(3, 2, 0x00000000); const same = (a, b) => a.width === b.width && a.height === b.height && Buffer.from(a.bitmap).equals(Buffer.from(b.bitmap)); assert(same(bordered().trim(), bordered().trim(undefined)) && same(bordered().trim(), bordered().trim({})), 'trim() without arguments'); - const frame = () => Frame.from(bordered(), 100, 3, 4); + const frame = () => Frame.from(bordered(), {duration: 100, xOffset: 3, yOffset: 4}); assert(frame().trim({color: 0x102030ff}).xOffset === 5, 'Frame.trim({color}) moves the offsets'); assert(same(bordered().colorToAlpha(), bordered().colorToAlpha(undefined)) && same(bordered().colorToAlpha(), bordered().colorToAlpha({})), 'colorToAlpha() without arguments'); const map = new GainMap(new Image(4, 4).fill(0x808080ff), {gainMapMin: 0, gainMapMax: 2}); @@ -1041,7 +1041,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); composite: v => image().composite(image(), {x: v, y: 0}), hueShift: v => image().hueShift(v), brightness: v => image().brightness(v), gamma: v => image().gamma(v), colorToAlpha: v => image().colorToAlpha({color: v}), rotate: v => image().rotate(v, {resize: false}), gradient: v => Colors.gradient({0: v, 1: 0}), roundCorners: v => image().roundCorners({radius: v}), fisheye: v => image().fisheye({strength: v}), encodePNG: v => image().encodePNG({compression: v}), encodeJPEG: v => image().encodeJPEG({quality: v}), - frame: v => new Frame(4, 4, v), animation: v => new Animation([new Frame(4, 4)], v), + frame: v => new Frame(4, 4, {duration: v}), animation: v => new Animation([new Frame(4, 4)], {loopCount: v}), }; for (const [name, call] of Object.entries(calls)) for (const value of [NaN, Infinity, -Infinity]) { diff --git a/tests/trim.js b/tests/trim.js index 01d2e733..416970b0 100644 --- a/tests/trim.js +++ b/tests/trim.js @@ -42,7 +42,7 @@ const size = image => `${image.width}x${image.height}`; // frames keep their position { - const frame = new Frame(10, 10, 100, 5, 7); + const frame = new Frame(10, 10, {duration: 100, xOffset: 5, yOffset: 7}); frame.drawBox(2, 3, 4, 4, 0xffffffff); frame.trim(); if (size(frame) !== '4x4' || frame.xOffset !== 7 || frame.yOffset !== 10) panic(`trim: frame ${size(frame)} at ${frame.xOffset},${frame.yOffset}`); diff --git a/tests/webp-lossy.js b/tests/webp-lossy.js index 8c722066..48e9e18e 100644 --- a/tests/webp-lossy.js +++ b/tests/webp-lossy.js @@ -142,7 +142,7 @@ const alphaReferences = { frame.drawBox(i * 10, 5, 10, 10, 0xffffffff); return frame; }); - const animation = new Animation(frames.map(f => Frame.from(f, 100)), 0); + const animation = new Animation(frames.map(f => Frame.from(f, {duration: 100})), {loopCount: 0}); const lossy = await animation.encodeWebP({quality: 80}); const decoded = await Animation.decode(lossy); if (decoded.length !== 3) panic(`lossy animation: ${decoded.length} frames instead of 3`); @@ -157,7 +157,7 @@ const alphaReferences = { if (!await animation.encodeWebP({quality: 200}).then(() => false, e => e instanceof RangeError)) panic('animation quality 200 should throw'); // alphaQuality for animations, also encoded a frame at a time - const faded = new Animation([0, 1].map(i => Frame.from(new Image(40, 30).fill((x, y) => Colors.fromRGBA(200, 50 * i, 9, x * 6 + y)), 100)), 0); + const faded = new Animation([0, 1].map(i => Frame.from(new Image(40, 30).fill((x, y) => Colors.fromRGBA(200, 50 * i, 9, x * 6 + y)), {duration: 100})), {loopCount: 0}); // (the same creation time: it's written in seconds, two encodes can be a second apart) const metadata = {creationTime: 0}; const fadedWebp = await faded.encodeWebP({quality: 80, alphaQuality: 0, optimize: false, metadata}); From d6f32534a1e907b36aedc3f90a244ef757f2cedc Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 17:05:53 +0000 Subject: [PATCH 016/188] reject arguments that are not options in all methods that take options --- CHANGELOG.md | 2 +- src/animation-encoder.ts | 3 +- src/animation.ts | 6 +++- src/gainmap.ts | 1 + src/image.ts | 23 ++++++++++++--- src/text-layout.ts | 3 ++ tests/options-objects-all.js | 55 ++++++++++++++++++++++++++++++++++++ 7 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 tests/options-objects-all.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 84e0a5eb..245da2ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Breaking changes -- **Methods that only take options refuse anything else**: `cropCircle()` and `Image.renderText()` throw a `TypeError` that names the working form (e.g. `cropCircle({ size: 'cover' })`) for the 3.x calls that are gone, like `cropCircle(true)` or `Image.renderText(font, 32, 'Hi')`, instead of ignoring them. +- **Methods that take options refuse anything else than an object**: a stray number, string, boolean or `null` as the options of `cropCircle()`, `Image.renderText()`, the encoders (`encodeWebP()`, `encodeAVIF()`, `encodeJXL()`, ...), `quantize()`, `transform()`, `dropShadow()`, `outline()`, the gradients, `Image.decode()`, the animation encoders, `new GainMap()` and `new TextLayout()` throws a `TypeError` that names the working form (e.g. `cropCircle({ size: 'cover' })`), for the 3.x calls that are gone like `cropCircle(true)` or `Image.renderText(font, 32, 'Hi')` and for typos, instead of ignoring it. - **The deprecated resize, SVG and frame disposal constants are removed**: `Image.RESIZE_NEAREST_NEIGHBOR`, `Image.RESIZE_BILINEAR`, `Image.RESIZE_BICUBIC`, `Image.RESIZE_AREA`, `Image.SVG_MODE_SCALE`, `Image.SVG_MODE_WIDTH`, `Image.SVG_MODE_HEIGHT` and `Frame.DISPOSAL_*` are gone: use `ResizeModes`, `SVGScaleModes` and `FrameDisposalModes` instead. `ResizeModes.AUTO` (and `Image.RESIZE_AUTO`) is removed too: pass only the width or the height, `image.resize({ width: 256 })`. The old values are no longer accepted either: `'RESIZE_BILINEAR'`, the numbers 1-3 for SVG scale modes and the numeric disposal ids 0-3. - **`absolute` is removed from `opacity()`, `red()`, `green()`, `blue()`, `lightness()` and `saturation()`**: they only scale now, `image.red(0.5)` (and `AdjustOptions`, the type of their options, is removed). For a fixed color, use `image.recolor()`. diff --git a/src/animation-encoder.ts b/src/animation-encoder.ts index 9c62517f..2b87d581 100644 --- a/src/animation-encoder.ts +++ b/src/animation-encoder.ts @@ -6,7 +6,7 @@ import { Frame, FrameOptions } from './frame.ts'; import { Optimizer, Compositor, InputFrame, OptimizeOptions, OptimizedFrame, coverCanvas, fillUnchanged } from './util/optimize.ts'; import { compressAsync, decompressAsync } from './codecs/zlib.ts'; import { resolveMetadata, pngText, exifData, gifComments } from './metadata.ts'; -import { finite, compressionLevel } from './util/options.ts'; +import { checkOptions, finite, compressionLevel } from './util/options.ts'; import type { AnimationOptions, WebPAnimationEncodeOptions, GIFEncodeOptions, APNGEncodeOptions } from './animation.ts'; // (ImageScript, like GIF, counts repetitions: -1 forever, 0 play once; APNG and WebP count plays, 0 forever) @@ -57,6 +57,7 @@ export class AnimationEncoder { * @param options See {@link FrameOptions} */ add(frame: Frame | Image, options: FrameOptions = {}): Promise { + checkOptions(options, 'encoder.add(frame, { duration: 40 })'); if (this.finished) throw new Error('The animation encoder is finished'); if (!(frame instanceof Image)) throw new TypeError('Invalid frame passed'); const own = frame instanceof Frame ? frame : null; diff --git a/src/animation.ts b/src/animation.ts index 1b83928a..e138ef37 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -317,7 +317,9 @@ export class Animation extends Array { * shows the same animation, but doesn't necessarily decode to the same frames * @param options See {@link WebPAnimationEncodeOptions} */ - async encodeWebP({ metadata = {}, optimize = true, quality, effort = 2, sharpYuv = false, alphaQuality = 100 }: WebPAnimationEncodeOptions = {}): Promise { + async encodeWebP(options: WebPAnimationEncodeOptions = {}): Promise { + checkOptions(options, 'animation.encodeWebP({ quality: 80 })'); + const { metadata = {}, optimize = true, quality, effort = 2, sharpYuv = false, alphaQuality = 100 } = options; this.__validate__(); if (!this.length) throw new Error('An animation needs at least one frame'); const lossy = webp.lossyOptions(quality, effort, sharpYuv, alphaQuality); @@ -391,6 +393,7 @@ export class Animation extends Array { * @param options See {@link WebPAnimationEncodeOptions}, and {@link AnimationOptions.loopCount} */ static webpEncoder(width: number, height: number, options: WebPAnimationEncodeOptions & AnimationOptions = {}): AnimationEncoder { + checkOptions(options, 'Animation.webpEncoder(width, height, { quality: 80 })'); return AnimationEncoder.__webp__(width, height, options); } @@ -402,6 +405,7 @@ export class Animation extends Array { * @param options See {@link GIFEncodeOptions}, and {@link AnimationOptions.loopCount} */ static gifEncoder(width: number, height: number, options: GIFEncodeOptions & AnimationOptions = {}): AnimationEncoder { + checkOptions(options, 'Animation.gifEncoder(width, height, { quality: 80 })'); return AnimationEncoder.__gif__(width, height, options); } diff --git a/src/gainmap.ts b/src/gainmap.ts index a07b83f9..00359cf7 100644 --- a/src/gainmap.ts +++ b/src/gainmap.ts @@ -74,6 +74,7 @@ export class GainMap { * @param options See {@link GainMapOptions} */ constructor(image: Image, options: GainMapOptions = {}) { + checkOptions(options, 'new GainMap(image, { gainMapMax: 2 })'); if (!(image instanceof Image)) throw new TypeError('The gain map has to be an Image'); const { gainMapMin = 0, gainMapMax = 2, gamma = 1, offsetSDR = 1 / 64, offsetHDR = 1 / 64, hdrCapacityMin = 0, useBaseColorSpace = true } = options; this.gainMapMin = perChannel(gainMapMin, 'gainMapMin'); diff --git a/src/image.ts b/src/image.ts index f8896376..61aad98d 100644 --- a/src/image.ts +++ b/src/image.ts @@ -762,6 +762,7 @@ export class Image { * @returns The new image */ static linearGradient(width: number, height: number, options: LinearGradientOptions): Image { + checkOptions(options, 'Image.linearGradient(100, 50, { stops, angle: 90 })'); const { stops, angle = 180 } = options; const color = gradientFunction(stops); finite(angle, 'angle'); @@ -785,6 +786,7 @@ export class Image { * @returns The new image */ static radialGradient(width: number, height: number, options: RadialGradientOptions): Image { + checkOptions(options, 'Image.radialGradient(100, 50, { stops })'); const { stops, x: cx = width / 2, y: cy = height / 2, radius = Math.hypot(Math.max(cx, width - cx), Math.max(cy, height - cy)) } = options; const color = gradientFunction(stops); finite(cx, 'x'); @@ -1810,6 +1812,7 @@ export class Image { * @param options See {@link QuantizeOptions} */ quantize(options: QuantizeOptions = {}): this { + checkOptions(options, 'image.quantize({ colors: 16 })'); if (typeof options !== 'object' || options === null) throw new TypeError('quantize takes an options object'); const { colors, palette, dither = 'none', ditherStrength = 1 } = options; @@ -1901,6 +1904,7 @@ export class Image { * @param options See {@link TransformOptions} */ transform(matrix: ArrayLike, options: TransformOptions = {}): this { + checkOptions(options, 'image.transform([1, 0, 0, 1, 0, 0], { expand: false })'); if (matrix === null || typeof matrix !== 'object' || matrix.length !== 6) throw new RangeError('A transform has 6 values: [a, b, c, d, e, f]'); // (numbers only: a string is a mistake in data like this) const values = Array.from({ length: 6 }, (_, i) => finite(typeof matrix[i] === 'number' ? matrix[i] : NaN, 'transform value')); @@ -1966,6 +1970,7 @@ export class Image { * @param options See {@link ShadowOptions} */ dropShadow(options: ShadowOptions = {}): this { + checkOptions(options, 'image.dropShadow({ blur: 8 })'); const { x = 0, y = 0, blur = 0, spread = 0, color = 0x00000080, expand = true } = options; finite(x, 'x'); finite(y, 'y'); @@ -2014,6 +2019,7 @@ export class Image { * @param options See {@link OutlineOptions} */ outline(options: OutlineOptions = {}): this { + checkOptions(options, 'image.outline({ width: 4 })'); const { width = 1, color = 0x000000ff, expand = true } = options; if (finite(width, 'outline width') < 0) throw new RangeError('Invalid outline width'); const [r, g, b, a] = Colors.toRGBA(checkColor(color)); @@ -2609,7 +2615,9 @@ export class Image { * quality * @param options See {@link WebPEncodeOptions} */ - async encodeWebP({ quality, effort = 2, sharpYuv = false, alphaQuality = 100, threads = defaultThreads(), metadata = {} }: WebPEncodeOptions = {}): Promise { + async encodeWebP(options: WebPEncodeOptions = {}): Promise { + checkOptions(options, 'image.encodeWebP({ quality: 80 })'); + const { quality, effort = 2, sharpYuv = false, alphaQuality = 100, threads = defaultThreads(), metadata = {} } = options; checkThreads(threads); const exif = exifData(resolveMetadata(metadata)); const lossy = webp.lossyOptions(quality, effort, sharpYuv, alphaQuality); @@ -2621,7 +2629,9 @@ export class Image { * Encodes the image as run length encoded TGA (32 bit, or 24 bit if the image is opaque) * @param options See {@link EncodeOptions} */ - async encodeTGA({ metadata = {} }: EncodeOptions = {}): Promise { + async encodeTGA(options: EncodeOptions = {}): Promise { + checkOptions(options, 'image.encodeTGA({ metadata })'); + const { metadata = {} } = options; const { author, comment, creationTime: time, software } = resolveMetadata(metadata); return tga.encode(this.__bytes__(), this.width, this.height, { author, comment, time, software }); } @@ -2645,7 +2655,9 @@ export class Image { * Encodes the image as AVIF (AV1, with an alpha image if the image has transparency) * @param options See {@link AVIFEncodeOptions} */ - async encodeAVIF({ quality = 60, alphaQuality = 100, subsampling = '4:4:4', effort = 1, threads = defaultThreads(), metadata = {}, gainMap = true }: AVIFEncodeOptions = {}): Promise { + async encodeAVIF(options: AVIFEncodeOptions = {}): Promise { + checkOptions(options, 'image.encodeAVIF({ quality: 60 })'); + const { quality = 60, alphaQuality = 100, subsampling = '4:4:4', effort = 1, threads = defaultThreads(), metadata = {}, gainMap = true } = options; checkEffort(effort, 2); checkThreads(threads); 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)}`); @@ -2686,7 +2698,9 @@ export class Image { * container, with the metadata as Exif * @param options See {@link JXLEncodeOptions} */ - async encodeJXL({ quality, effort = 1, threads = defaultThreads(), metadata = {}, gainMap = true }: JXLEncodeOptions = {}): Promise { + async encodeJXL(options: JXLEncodeOptions = {}): Promise { + checkOptions(options, 'image.encodeJXL({ quality: 90 })'); + const { quality, effort = 1, threads = defaultThreads(), metadata = {}, gainMap = true } = options; checkEffort(effort, 2); checkThreads(threads); // (cjxl's distance of a quality) @@ -2715,6 +2729,7 @@ export class Image { * @return The decoded image */ static async decode(input: BinaryData, options: DecodeOptions = {}): Promise { + checkOptions(options, 'Image.decode(data, { maxPixels: 1e8 })'); const { applyOrientation = true, applyColorProfile = true, maxPixels = MAX_PIXELS, toneMapping = 'reinhard', threads = defaultThreads(), gainMap = true } = options; checkThreads(threads); if (!(Number(maxPixels) > 0)) throw new RangeError(`maxPixels must be a positive number, not ${maxPixels}`); diff --git a/src/text-layout.ts b/src/text-layout.ts index eadfd0e4..75ae91b3 100644 --- a/src/text-layout.ts +++ b/src/text-layout.ts @@ -1,3 +1,5 @@ +import { checkOptions } from './util/options.ts'; + /** Where lines are wrapped: between words or at any character */ export type WrapStyle = 'word' | 'char'; /** Alignment of the lines relative to the widest line */ @@ -54,6 +56,7 @@ export class TextLayout { * @param options See {@link TextLayoutOptions} */ constructor(options: TextLayoutOptions = {}) { + checkOptions(options, 'new TextLayout({ maxWidth: 300 })'); const { maxWidth, maxHeight, wrapStyle, verticalAlign, horizontalAlign, wrapHardBreaks } = options; // (undefined and 0 mean no limit, NaN is invalid) diff --git a/tests/options-objects-all.js b/tests/options-objects-all.js new file mode 100644 index 00000000..62dac043 --- /dev/null +++ b/tests/options-objects-all.js @@ -0,0 +1,55 @@ +const {Image, Frame, Animation, GainMap, TextLayout} = require('..'); + +const panic = message => { + console.error(message); + process.exit(1); +}; + +// the options of every method that takes them must be an object (or left out): a stray number, string or boolean is a +// call in a form that doesn't exist (the 3.x positional forms, or a typo), so it throws instead of being ignored +(async () => { + const photo = new Image(16, 10).fill((x, y) => 0x336699ff + x * 0x01000000 + y * 0x00010000 >>> 0); + const frame = Frame.from(photo); + const gainMap = new GainMap(new Image(4, 3).fill(0x808080ff), {gainMapMax: 2}); + const calls = { + 'quantize': bad => photo.clone().quantize(bad), + 'transform': bad => photo.clone().transform([1, 0, 0, 1, 0, 0], bad), + 'dropShadow': bad => photo.clone().dropShadow(bad), + 'outline': bad => photo.clone().outline(bad), + 'Image.linearGradient': bad => Image.linearGradient(8, 8, bad), + 'Image.radialGradient': bad => Image.radialGradient(8, 8, bad), + 'Image.decode': async bad => Image.decode(await photo.encodePNG(), bad), + 'encodeWebP': bad => photo.encodeWebP(bad), + 'encodeAVIF': bad => photo.encodeAVIF(bad), + 'encodeJXL': bad => photo.encodeJXL(bad), + 'encodeTGA': bad => photo.encodeTGA(bad), + 'Animation.encodeWebP': bad => new Animation([frame]).encodeWebP(bad), + 'Animation.webpEncoder': bad => Animation.webpEncoder(8, 8, bad), + 'Animation.gifEncoder': bad => Animation.gifEncoder(8, 8, bad), + 'AnimationEncoder.add': async bad => { const encoder = Animation.gifEncoder(16, 10); await encoder.add(photo, bad); }, + 'new GainMap': bad => new GainMap(new Image(2, 2), bad), + 'new TextLayout': bad => new TextLayout(bad) + }; + for (const [name, call] of Object.entries(calls)) { + for (const [label, bad] of Object.entries({'a number': 80, 'a string': 'fast', 'true': true, 'null': null})) { + let error = null; + try { await call(bad); } catch (e) { error = e; } + if (!(error instanceof TypeError)) panic(`${name}(${label}): should throw a TypeError, got ${error}`); + if (!/takes an options object/.test(error.message)) panic(`${name}(${label}): the message should name the options form, got ${error.message}`); + } + } + + // in place: nothing changes when the call is refused + const image = photo.clone(); + for (const call of [() => image.quantize(16), () => image.dropShadow(8), () => image.outline(2), () => image.transform([1, 0, 0, 1, 0, 0], 'bicubic')]) { + try { call(); } catch { /* refused */ } + } + if (image.width !== 16 || image.height !== 10 || !Buffer.from(image.bitmap).equals(Buffer.from(photo.bitmap))) panic('a refused call should leave the image as it was'); + + // the options forms (and no options at all) still work + photo.clone().quantize().quantize({colors: 4}); + photo.clone().dropShadow().outline({width: 1}); + if ((await photo.encodeWebP({quality: 50})).length < 10 || (await photo.encodeWebP()).length < 10) panic('encodeWebP with and without options'); + if (new TextLayout().maxWidth !== undefined && new TextLayout({maxWidth: 30}).maxWidth !== 30) panic('TextLayout options'); + if (gainMap.width !== 4) panic('GainMap options'); +})().catch(error => panic(error.stack || String(error))); From 8a73778eb20a2651f5f700bd4c85376235329a0c Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 17:17:36 +0000 Subject: [PATCH 017/188] decode() returns an Animation for files with several frames --- CHANGELOG.md | 2 ++ src/index.ts | 46 +++++++++++++++---------- tests/decode-frames.js | 76 ++++++++++++++++++++++++++++++++++++++++++ tests/options.js | 3 +- 4 files changed, 109 insertions(+), 18 deletions(-) create mode 100644 tests/decode-frames.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 245da2ff..6311ce46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - **The positional arguments of `new Frame()`, `Frame.from()` and `new Animation()` are removed, and `Animation.decode()` takes no flag**: `new Frame(width, height, duration, xOffset, yOffset, disposalMode)` is `new Frame(width, height, { duration, xOffset, yOffset, disposalMode })`, `Frame.from(image, duration, xOffset, yOffset, disposalMode)` is `Frame.from(image, { duration, xOffset, yOffset, disposalMode })`, `new Animation(frames, loopCount)` is `new Animation(frames, { loopCount })`, and `Animation.decode(data, true)` is `Animation.decode(data, { onlyFirstFrame: true })`. The `GIF` class keeps its arguments until it is removed. +- **`decode()` returns an `Animation` only for files with several frames**: a GIF, APNG, animated WebP or JPEG XL with more than one frame is an `Animation` (`decode(gif)` used to be a `GIF`), and one with a single frame is an `Image`, like a still image (a one-frame GIF used to be a `GIF`). With `onlyFirstFrame`, the first frame comes back as an `Image`, since the frame count isn't read. + ## 3.20.1 ### Performance diff --git a/src/index.ts b/src/index.ts index 20d20630..fdfa8abc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,9 @@ import { BinaryData } from './util/mem.ts'; import { Image, DecodeOptions } from './image.ts'; -import { GIF } from './gif.ts'; +import { Frame } from './frame.ts'; import { Animation, AnimationDecodeOptions } from './animation.ts'; import { isOptions } from './util/options.ts'; +import * as png from './codecs/png.ts'; import * as webp from './codecs/webp.ts'; import { view } from './util/mem.ts'; import { ImageType } from './image-type.ts'; @@ -27,25 +28,36 @@ 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 - * {@link Animation}, everything else (including APNGs, as their default image) as {@link Image}. - * {@link Animation.decode} decodes the frames of GIFs, APNGs and WebPs + * Decodes any supported image. A file with more than one frame (GIF, APNG, animated WebP, animated JPEG XL) is + * returned as an {@link Animation} of its frames, and any other file as an {@link Image}: a file with a single frame + * (also a GIF, APNG, WebP or JPEG XL with one frame) becomes an Image of that frame, without its offsets and duration. + * Static formats (PNG, JPEG, AVIF, ...) are always Images, AVIF image sequences decode to their first frame, as + * {@link Image.decode} does. The options are passed on to {@link Animation.decode} or {@link Image.decode}. * @param data The image data - * @param options See {@link AnimationDecodeOptions} (for animations) and {@link DecodeOptions} (for images) + * @param options See {@link AnimationDecodeOptions} and {@link DecodeOptions} */ -export function decode(data: BinaryData, options?: AnimationDecodeOptions & DecodeOptions): Promise; +export function decode(data: BinaryData, options?: AnimationDecodeOptions & DecodeOptions): Promise; /** - * Decodes any supported image: GIFs are returned as {@link GIF} (an {@link Animation}), animated WebPs as - * {@link Animation}, everything else (including APNGs, as their default image) as {@link Image}. - * {@link Animation.decode} decodes the frames of GIFs, APNGs and WebPs + * Decodes any supported image. A file with more than one frame is returned as an {@link Animation}, anything else as + * an {@link Image} (see the overload with options). With `onlyFirstFrame` the rest of the file isn't decoded, so the + * frame count isn't known: the first frame is returned as an Image. * @param data The image data - * @param onlyExtractFirstFrame Whether to end animation decoding after the first frame + * @param onlyFirstFrame Whether to end animation decoding after the first frame */ -export function decode(data: BinaryData, onlyExtractFirstFrame?: boolean): Promise; -export function decode(data: BinaryData, onlyFirstFrameOrOptions: boolean | (AnimationDecodeOptions & DecodeOptions) = false): Promise { - const { onlyFirstFrame = false, applyOrientation, maxPixels, toneMapping } = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions : { onlyFirstFrame: onlyFirstFrameOrOptions }; - const type = ImageType.getType(data); - if (type === 'gif') return GIF.decode(data, { onlyFirstFrame, maxPixels }); - if (type === 'webp' && webp.isAnimated(view(data))) return Animation.decode(data, { onlyFirstFrame, maxPixels }); - return Image.decode(data, { applyOrientation, maxPixels, toneMapping }); +export function decode(data: BinaryData, onlyFirstFrame?: boolean): Promise; +export async function decode(data: BinaryData, onlyFirstFrameOrOptions: boolean | (AnimationDecodeOptions & DecodeOptions) = false): Promise { + const options: AnimationDecodeOptions & DecodeOptions = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions : { onlyFirstFrame: onlyFirstFrameOrOptions }; + const bytes = view(data); + const type = ImageType.getType(bytes); + const animated = type === 'gif' || type === 'jxl' || (type === 'webp' && webp.isAnimated(bytes)) || (type === 'png' && png.isAnimated(bytes)); + if (!animated) return Image.decode(data, options); + + const animation = await Animation.decode(data, options); + if (animation.length !== 1) return animation; + // the only frame, as an image: its pixels and gain map + const frame: Frame = animation.frames[0]; + const image = new Image(frame.width, frame.height); + image.bitmap = frame.bitmap; + image.gainMap = frame.gainMap; + return image; } diff --git a/tests/decode-frames.js b/tests/decode-frames.js new file mode 100644 index 00000000..3989301b --- /dev/null +++ b/tests/decode-frames.js @@ -0,0 +1,76 @@ +const fs = require('fs').promises; +const crypto = require('crypto'); +const {Image, Frame, Animation, Colors, decode} = require('..'); + +const panic = message => { + console.error(message); + process.exit(1); +}; +const sha256 = bytes => crypto.createHash('sha256').update(new Uint8Array(bytes)).digest('hex'); +const throwsA = async (fn, type, label) => { + let error = null; + try { await fn(); } catch (e) { error = e; } + if (!(error instanceof type)) panic(`${label}: should throw a ${type.name}, got ${error}`); +}; + +(async () => { + const photo = new Image(12, 8).fill((x, y) => Colors.fromRGBA(x * 20, y * 30, 90, 255)); + const two = new Animation([Frame.from(photo, {duration: 50}), Frame.from(photo.clone().invert(), {duration: 70})]); + + // multiple frames: an Animation, with every frame + const gif = await fs.readFile('./tests/targets/gif.gif'); + const gifFrames = await decode(gif); + if (!(gifFrames instanceof Animation) || gifFrames.length !== 30) panic(`GIF: ${gifFrames.constructor.name} with ${gifFrames.length} frames instead of 30`); + if ((await Animation.decode(gif)).length !== 30) panic('GIF: Animation.decode frame count changed'); + + const apng = await two.encodeAPNG({optimize: false}); + const apngFrames = await decode(apng); + if (!(apngFrames instanceof Animation) || apngFrames.length !== 2 || apngFrames.frames[1].duration !== 70) panic('APNG: expected an Animation of 2 frames'); + + const webp = await fs.readFile('./tests/webp/animated.webp'); + const webpFrames = await decode(webp); + if (!(webpFrames instanceof Animation) || webpFrames.length !== 4) panic(`animated WebP: ${webpFrames.constructor.name} with ${webpFrames.length} frames instead of 4`); + + // (JPEG XL animations: the frames of the APNG reference) + const jxlAnimation = await fs.readFile('./tests/jxl/animation.jxl'); + const jxlRef = await Animation.decode(await fs.readFile('./tests/jxl/animation.png')); + const jxlFrames = await decode(jxlAnimation); + if (!(jxlFrames instanceof Animation) || jxlFrames.length !== jxlRef.length) panic(`JPEG XL: ${jxlFrames.constructor.name} with ${jxlFrames.length} frames instead of ${jxlRef.length}`); + + // one frame: an Image with the frame's pixels, as Animation.decode's first frame has them + const single = new Animation([new Frame(12, 8, {duration: 90, xOffset: 0, yOffset: 0}).fill((x, y) => Colors.fromRGBA(x * 20, y * 30, 90, 255))]); + for (const [format, bytes] of [['GIF', await single.encodeGIF()], ['APNG', await single.encodeAPNG()], ['WebP', await single.encodeWebP({quality: 90})]]) { + const image = await decode(bytes); + if (image instanceof Animation || !(image instanceof Image)) panic(`single frame ${format}: ${image.constructor.name} instead of an Image`); + const reference = (await Animation.decode(bytes)).frames[0]; + if (image.width !== reference.width || image.height !== reference.height) panic(`single frame ${format}: ${image.width}x${image.height} instead of ${reference.width}x${reference.height}`); + if (sha256(image.bitmap) !== sha256(reference.bitmap)) panic(`single frame ${format}: pixels differ from Animation.decode's first frame`); + } + + // a single frame with an offset: the image is the canvas, the offset is dropped + const offset = await new Animation([new Frame(4, 3, {xOffset: 2, yOffset: 1, duration: 40}).fill(0xff0000ff)]).encodeGIF({optimize: false}); + const offsetImage = await decode(offset); + if (offsetImage instanceof Animation || offsetImage.width !== 6 || offsetImage.height !== 4) panic(`single frame with an offset: ${offsetImage.width}x${offsetImage.height}`); + + // a still JPEG XL is an Image with the pixels Image.decode gives + const still = await fs.readFile('./tests/jxl/lossy.jxl'); + const stillImage = await decode(still), stillRef = await Image.decode(still); + if (stillImage instanceof Animation || stillImage.width !== stillRef.width || sha256(stillImage.bitmap) !== sha256(stillRef.bitmap)) panic('still JPEG XL: pixels differ from Image.decode'); + + // static formats: always Images, the pixels of Image.decode + for (const [name, path] of [['PNG', './tests/targets/external.png'], ['JPEG', './tests/targets/external.jpg'], ['still WebP', './tests/webp/lossy.webp']]) { + const bytes = await fs.readFile(path); + const image = await decode(bytes), reference = await Image.decode(bytes); + if (image instanceof Animation || !(image instanceof Image)) panic(`${name}: not an Image`); + if (sha256(image.bitmap) !== sha256(reference.bitmap)) panic(`${name}: pixels differ from Image.decode`); + } + + // options pass through: onlyFirstFrame gives the first frame as an Image, maxPixels applies to both kinds + const first = await decode(gif, {onlyFirstFrame: true}); + if (first instanceof Animation || !(first instanceof Image)) panic('onlyFirstFrame: expected an Image'); + if (sha256(first.bitmap) !== sha256((await Animation.decode(gif, {onlyFirstFrame: true})).frames[0].bitmap)) panic('onlyFirstFrame: wrong frame'); + await throwsA(() => decode(gif, {maxPixels: 100}), RangeError, 'maxPixels on a GIF'); + const jpeg = await fs.readFile('./tests/targets/external.jpg'); + await throwsA(() => decode(jpeg, {maxPixels: 100}), RangeError, 'maxPixels on a JPEG'); + if ((await decode(gif, {threads: 2})).length !== 30) panic('threads: frames changed'); +})().catch(error => panic(error.stack || String(error))); diff --git a/tests/options.js b/tests/options.js index d580e0ef..f8ab966e 100644 --- a/tests/options.js +++ b/tests/options.js @@ -49,8 +49,9 @@ const same = (name, a, b) => { if (!Buffer.from(await animation[method]({metadata: {author, comment: author}})).includes(author)) panic(`${method}: metadata missing`); const gif = await animation.encodeGIF(); - for (const decoded of [await Animation.decode(gif, {onlyFirstFrame: true}), await GIF.decode(gif, {onlyFirstFrame: true}), await decode(gif, {onlyFirstFrame: true})]) + for (const decoded of [await Animation.decode(gif, {onlyFirstFrame: true}), await GIF.decode(gif, {onlyFirstFrame: true})]) if (decoded.length !== 1) panic('decode: onlyFirstFrame not applied'); + if (!(await decode(gif, {onlyFirstFrame: true}) instanceof Image)) panic('decode: onlyFirstFrame should return an Image'); if ((await decode(gif, {})).length !== 2) panic('decode: wrong default'); // decode() passes applyOrientation on to Image.decode() From da60e9ef9ff21870dd1256229f974bf63ef9de75 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 17:19:20 +0000 Subject: [PATCH 018/188] remove the GIF class --- CHANGELOG.md | 4 +++- scripts/docs.js | 2 +- src/animation.ts | 14 +++--------- src/gif.ts | 53 ------------------------------------------- src/index.ts | 1 - tests/animation.js | 4 ++-- tests/gif.js | 20 ++++++++-------- tests/misc.js | 12 +++++----- tests/options-only.js | 3 +-- tests/options.js | 7 +++--- tests/regressions.js | 15 ++++++------ 11 files changed, 36 insertions(+), 99 deletions(-) delete mode 100644 src/gif.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6311ce46..fdc7bfae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,10 +31,12 @@ - **The positional arguments of `Image.renderSVG()` are removed**: `Image.renderSVG(svg, size, mode)` is `Image.renderSVG(svg, { size, mode })`, which takes the same `fonts` and `images` as before: `Image.renderSVG(svg, 2, 'width')` is now `Image.renderSVG(svg, { size: 2, mode: 'width' })`. -- **The positional arguments of `new Frame()`, `Frame.from()` and `new Animation()` are removed, and `Animation.decode()` takes no flag**: `new Frame(width, height, duration, xOffset, yOffset, disposalMode)` is `new Frame(width, height, { duration, xOffset, yOffset, disposalMode })`, `Frame.from(image, duration, xOffset, yOffset, disposalMode)` is `Frame.from(image, { duration, xOffset, yOffset, disposalMode })`, `new Animation(frames, loopCount)` is `new Animation(frames, { loopCount })`, and `Animation.decode(data, true)` is `Animation.decode(data, { onlyFirstFrame: true })`. The `GIF` class keeps its arguments until it is removed. +- **The positional arguments of `new Frame()`, `Frame.from()` and `new Animation()` are removed, and `Animation.decode()` takes no flag**: `new Frame(width, height, duration, xOffset, yOffset, disposalMode)` is `new Frame(width, height, { duration, xOffset, yOffset, disposalMode })`, `Frame.from(image, duration, xOffset, yOffset, disposalMode)` is `Frame.from(image, { duration, xOffset, yOffset, disposalMode })`, `new Animation(frames, loopCount)` is `new Animation(frames, { loopCount })`, and `Animation.decode(data, true)` is `Animation.decode(data, { onlyFirstFrame: true })`. - **`decode()` returns an `Animation` only for files with several frames**: a GIF, APNG, animated WebP or JPEG XL with more than one frame is an `Animation` (`decode(gif)` used to be a `GIF`), and one with a single frame is an `Image`, like a still image (a one-frame GIF used to be a `GIF`). With `onlyFirstFrame`, the first frame comes back as an `Image`, since the frame count isn't read. +- **The `GIF` class is removed**: `new GIF(frames, loopCount)` is `new Animation(frames, { loopCount })`, `GIF.decode(data)` is `Animation.decode(data)` and `gif.encode(quality)` is `animation.encodeGIF({ quality })`. `Animation` decodes and encodes GIFs the same way it does APNGs and WebPs. + ## 3.20.1 ### Performance diff --git a/scripts/docs.js b/scripts/docs.js index 8cb6530c..6f51c094 100644 --- a/scripts/docs.js +++ b/scripts/docs.js @@ -63,7 +63,7 @@ for (const page of pages) { const redirects = { 'Image.html': ['classes/Image.html', true], 'Frame.html': ['classes/Frame.html', true], - 'GIF.html': ['classes/GIF.html', true], + 'GIF.html': ['classes/Animation.html', true], 'TextLayout.html': ['classes/TextLayout.html', true], 'global.html': ['functions/decode.html', false] }; diff --git a/src/animation.ts b/src/animation.ts index e138ef37..6441facf 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -456,7 +456,7 @@ export class Animation extends Array { const data = view(input); const type = ImageType.getType(data); - if (type === 'gif') return Animation.__fromGIF__(data, onlyExtractFirstFrame, Animation, maxPixels); + if (type === 'gif') return Animation.__gifFrames__(data, onlyExtractFirstFrame, maxPixels); if (type === 'webp') { const decoded = webp.decodeAnimation(data, onlyExtractFirstFrame, maxPixels); @@ -516,17 +516,9 @@ export class Animation extends Array { * @internal * @ignore */ - static __fromGIF__(data: Uint8Array, onlyExtractFirstFrame: boolean, Class: new (frames: Frame[], options: AnimationOptions) => T, maxPixels = MAX_PIXELS): Promise { - return decoding(() => Animation.__gifFrames__(data, onlyExtractFirstFrame, Class, maxPixels)); - } - - /** - * @internal - * @ignore - */ - private static __gifFrames__(data: Uint8Array, onlyExtractFirstFrame: boolean, Class: new (frames: Frame[], options: AnimationOptions) => T, maxPixels: number): T { + private static __gifFrames__(data: Uint8Array, onlyExtractFirstFrame: boolean, maxPixels: number): Animation { const decoded = gifCodec.decode(data, onlyExtractFirstFrame, maxPixels); const frames = gifCodec.composite(decoded).map(({ pixels, delay }) => ({ pixels, duration: 10 * delay })); - return new Class(canvasFrames(decoded.width, decoded.height, frames), { loopCount: decoded.loopCount }); + return new Animation(canvasFrames(decoded.width, decoded.height, frames), { loopCount: decoded.loopCount }); } } diff --git a/src/gif.ts b/src/gif.ts deleted file mode 100644 index 0b667bb6..00000000 --- a/src/gif.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { BinaryData, view } from './util/mem.ts'; -import { Frame } from './frame.ts'; -import { ImageType } from './image-type.ts'; -import { Animation, AnimationDecodeOptions, AnimationOptions, GIFEncodeOptions } from './animation.ts'; -import { isOptions } from './util/options.ts'; - -/** - * Represents a GIF image as an array of frames, an {@link Animation} whose `encode()` and `decode()` work with GIFs. - * Array methods like map and filter return plain arrays. - * @deprecated Use {@link Animation}, which works the same and also handles APNG: `new Animation(frames, { loopCount })`, - * {@link Animation.decode} and {@link Animation.encodeGIF}. `GIF` will be removed in 4.0 - * @group Deprecated - */ -export class GIF extends Animation { - /** - * Creates a new GIF image - * @param frames The frames to create the GIF from - * @param loopCountOrOptions How many times to repeat the GIF (`Infinity` = forever, 0 = play once), or see - * {@link AnimationOptions} - */ - constructor(frames: Frame[], loopCountOrOptions?: number | AnimationOptions) { - super(frames, isOptions(loopCountOrOptions) || loopCountOrOptions === undefined ? loopCountOrOptions : { loopCount: loopCountOrOptions }); - } - - /** A short description for debugging, e.g. `GIF<64x64x1000ms>` */ - override toString(): string { - return `GIF<${this.width}x${this.height}x${this.duration}ms>`; - } - - /** - * Encodes the GIF - * @param qualityOrOptions 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above), or - * see {@link GIFEncodeOptions} - * @deprecated Use {@link Animation.encodeGIF} - */ - async encode(qualityOrOptions?: number | GIFEncodeOptions): Promise { - return this.encodeGIF(isOptions(qualityOrOptions) || qualityOrOptions === undefined ? qualityOrOptions : { quality: qualityOrOptions }); - } - - /** - * Decodes a GIF into fully composited frames - * @param input The binary data to decode - * @param onlyFirstFrameOrOptions Whether to end GIF decoding after the first frame, or see - * {@link AnimationDecodeOptions} - * @deprecated Use {@link Animation.decode}, which decodes APNGs too - */ - static override async decode(input: BinaryData, onlyFirstFrameOrOptions: boolean | AnimationDecodeOptions = false): Promise { - const { onlyFirstFrame = false, maxPixels } = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions : { onlyFirstFrame: onlyFirstFrameOrOptions }; - const data = view(input); - if (!ImageType.isGIF(new DataView(data.buffer, data.byteOffset, data.byteLength))) throw new Error('Unsupported image type'); - return Animation.__fromGIF__(data, onlyFirstFrame, GIF, maxPixels); - } -} diff --git a/src/index.ts b/src/index.ts index fdfa8abc..13349adf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,6 @@ export { Frame, FrameDisposalModes } from './frame.ts'; export type { FrameDisposalModeName, FrameOptions } from './frame.ts'; export { GainMap } from './gainmap.ts'; export type { GainMapOptions, GainMapEncodeOptions } from './gainmap.ts'; -export { GIF } from './gif.ts'; export { Animation } from './animation.ts'; export type { AnimationOptions, AnimationDecodeOptions, AnimationEncodeOptions, WebPAnimationEncodeOptions, GIFEncodeOptions, APNGEncodeOptions } from './animation.ts'; export { AnimationEncoder } from './animation-encoder.ts'; diff --git a/tests/animation.js b/tests/animation.js index 528411f6..a52dbe5b 100644 --- a/tests/animation.js +++ b/tests/animation.js @@ -1,4 +1,4 @@ -const {Image, Frame, Animation, GIF, Colors} = require('..'); +const {Image, Frame, Animation, Colors} = require('..'); const panic = message => { console.error(message); @@ -51,7 +51,7 @@ const square = (color, w, h) => new Image(w, h).fill((x, y) => (x + y) % 5 === 0 // still images decode to a single frame; GIFs stay GIFs const single = await Animation.decode(await still.encodePNG()); if (single.length !== 1 || single.width !== 8) panic('Animation.decode should accept still images'); - const gif = await Animation.decode(await new GIF([Frame.from(still)]).encode()); + const gif = await Animation.decode(await new Animation([Frame.from(still)]).encodeGIF()); if (!(gif instanceof Animation) || gif.length !== 1) panic('Animation.decode should decode GIFs'); if (!(gif.slice(0, 1) instanceof Animation)) panic('slice should keep the class'); diff --git a/tests/gif.js b/tests/gif.js index 611654be..fb8fc2d9 100644 --- a/tests/gif.js +++ b/tests/gif.js @@ -1,6 +1,6 @@ const fs = require('fs').promises; const crypto = require('crypto'); -const {Frame, FrameDisposalModes, GIF, Colors} = require('..'); +const {Frame, FrameDisposalModes, Animation, Colors} = require('..'); const panic = message => { console.error(message); @@ -15,14 +15,14 @@ const panic = message => { frames.push(frame); } - const gif = new GIF(frames); + const gif = new Animation(frames); if (process.env.OVERWRITE_TEST) - await fs.writeFile('./tests/targets/gif.gif', await gif.encode(1)); + await fs.writeFile('./tests/targets/gif.gif', await gif.encodeGIF({quality: 1})); { const binary = await fs.readFile('./tests/targets/gif.gif'); - const decoded = await GIF.decode(binary); + const decoded = await Animation.decode(binary); if (decoded.width !== gif.width) panic('decode: incorrect width'); if (decoded.height !== gif.height) @@ -35,14 +35,14 @@ const panic = message => { panic('decode: incorrect frame bitmap length'); } - const first = await GIF.decode(binary, true); + const first = await Animation.decode(binary, {onlyFirstFrame: true}); if (first.length !== 1) panic('decode: onlyExtractFirstFrame returned more than one frame'); } { // frames with <= 256 colors survive a roundtrip losslessly - const decoded = await GIF.decode(await gif.encode()); + const decoded = await Animation.decode(await gif.encodeGIF()); for (let i = 0; i < gif.length; i++) if (!Buffer.from(decoded[i].bitmap).equals(Buffer.from(gif[i].bitmap))) panic(`roundtrip: frame ${i} differs`); @@ -66,7 +66,7 @@ const panic = message => { const last = new Frame(1, 1, {duration: 100, xOffset: 0, yOffset: 0}); last.fill(white); - const decoded = await GIF.decode(await new GIF([background, dot, hole, last], 2).encode()); + const decoded = await Animation.decode(await new Animation([background, dot, hole, last], {loopCount: 2}).encodeGIF()); if (decoded.loopCount !== 2) panic('disposal: incorrect loop count'); @@ -88,7 +88,7 @@ const panic = message => { // interlaced: written by Pillow (scripts/gif-fixtures.py), the sha256 of its RGBA pixels as Pillow decodes them { - const [frame] = await GIF.decode(await fs.readFile('./tests/gifs/interlaced.gif')); + const [frame] = await Animation.decode(await fs.readFile('./tests/gifs/interlaced.gif')); if (frame.width !== 80 || frame.height !== 37) panic('interlaced: dimensions don\'t match'); if (crypto.createHash('sha256').update(new Uint8Array(frame.bitmap)).digest('hex') !== '854e5e98f2124a6009d5bc28748772ede25af244a2503021ef4f52e323309d46') panic('interlaced: pixels don\'t match'); @@ -97,10 +97,10 @@ const panic = message => { // damaged files keep the frames decoded so far: cut off, or an unknown block before the trailer { const binary = await fs.readFile('./tests/targets/gif.gif'); - const cut = await GIF.decode(binary.subarray(0, binary.length >> 1)); + const cut = await Animation.decode(binary.subarray(0, binary.length >> 1)); if (!cut.length || cut.length >= 30) panic(`truncated: ${cut.length} frames decoded`); - const unknown = await GIF.decode(Buffer.concat([binary.subarray(0, -1), Buffer.from([0x99, 0x3b])])); + const unknown = await Animation.decode(Buffer.concat([binary.subarray(0, -1), Buffer.from([0x99, 0x3b])])); if (unknown.length !== 30) panic(`unknown block: ${unknown.length} frames decoded`); } })(); diff --git a/tests/misc.js b/tests/misc.js index f548e69a..ad0e407e 100644 --- a/tests/misc.js +++ b/tests/misc.js @@ -1,4 +1,4 @@ -const {Image, Frame, GIF, Colors} = require('..'); +const {Image, Frame, Animation, Colors} = require('..'); const panic = msg => { console.error(msg); process.exit(1); @@ -192,14 +192,14 @@ if (new Frame(1, 2, {duration: 3}).toString() !== 'Frame<1x2x3ms>') panic('frame } try { - new GIF([{}]); - panic('gif frame instanceof failed'); + new Animation([{}]); + panic('animation frame instanceof failed'); } catch { } { const frame = new Frame(512, 128, {duration: 123}); - const gif = new GIF([frame]); - if (gif.toString() !== 'GIF<512x128x123ms>') panic('gif tostring failed'); - if (gif.duration !== 123) panic('gif duration failed'); + const animation = new Animation([frame]); + if (animation.toString() !== 'Animation<512x128x123ms>') panic('animation tostring failed'); + if (animation.duration !== 123) panic('animation duration failed'); } diff --git a/tests/options-only.js b/tests/options-only.js index bd460e06..a0832e7e 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -1,4 +1,4 @@ -const {Image, Frame, Animation, GIF, GainMap, ResizeModes} = require('..'); +const {Image, Frame, Animation, GainMap, ResizeModes} = require('..'); const panic = message => { console.error(message); @@ -118,7 +118,6 @@ const sameImage = (image, reference, label) => { if (new Animation([new Frame(4, 4)], {loopCount: 2}).loopCount !== 2 || new Animation([new Frame(4, 4)]).loopCount !== -1) panic('new Animation(frames, options)'); const encodedGIF = await new Animation([new Frame(4, 4)]).encodeGIF(); if ((await Animation.decode(encodedGIF, {onlyFirstFrame: true})).length !== 1) panic('Animation.decode(data, { onlyFirstFrame })'); - if (new GIF([new Frame(4, 4)], 2).loopCount !== 2) panic('GIF keeps its 3.x loop count argument'); const oldFrames = { 'new Frame(4, 4, 100)': () => new Frame(4, 4, 100), 'new Frame(4, 4, 100, 1, 1, "keep")': () => new Frame(4, 4, 100, 1, 1, 'keep'), diff --git a/tests/options.js b/tests/options.js index f8ab966e..a832fdf3 100644 --- a/tests/options.js +++ b/tests/options.js @@ -1,5 +1,5 @@ const fs = require('fs').promises; -const {Image, Frame, Animation, GIF, decode} = require('..'); +const {Image, Frame, Animation, decode} = require('..'); const panic = message => { console.error(message); @@ -42,15 +42,14 @@ const same = (name, a, b) => { if (new Frame(1, 1, {}).duration !== 100 || new Frame(1, 1, {}).disposalMode !== 'keep') panic('Frame: wrong defaults'); const frames = () => [Frame.from(tile(), {duration: 50}), Frame.from(tile().invert(), {duration: 50})]; - if (new Animation(frames(), {loopCount: 2}).loopCount !== 2 || new GIF(frames(), {loopCount: 2}).loopCount !== 2) panic('Animation: loopCount not applied'); + if (new Animation(frames(), {loopCount: 2}).loopCount !== 2) panic('Animation: loopCount not applied'); const animation = new Animation(frames()); // (GIFs only store comments) for (const method of ['encodeGIF', 'encodeAPNG', 'encodeWebP']) if (!Buffer.from(await animation[method]({metadata: {author, comment: author}})).includes(author)) panic(`${method}: metadata missing`); const gif = await animation.encodeGIF(); - for (const decoded of [await Animation.decode(gif, {onlyFirstFrame: true}), await GIF.decode(gif, {onlyFirstFrame: true})]) - if (decoded.length !== 1) panic('decode: onlyFirstFrame not applied'); + if ((await Animation.decode(gif, {onlyFirstFrame: true})).length !== 1) panic('decode: onlyFirstFrame not applied'); if (!(await decode(gif, {onlyFirstFrame: true}) instanceof Image)) panic('decode: onlyFirstFrame should return an Image'); if ((await decode(gif, {})).length !== 2) panic('decode: wrong default'); diff --git a/tests/regressions.js b/tests/regressions.js index 578d190c..175ef0f7 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -1,4 +1,4 @@ -const {Image, Frame, GIF, Animation, TextLayout} = require('..'); +const {Image, Frame, Animation, TextLayout} = require('..'); const {ResizeModes, SVGScaleModes, FrameDisposalModes, Colors} = require('..'); const zlib = require('zlib'); // (encoders stamp the current time into the metadata: tests that compare two encodes freeze it, or they differ when a second ends in between) @@ -99,7 +99,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // GIFs behave like arrays { - const gif = new GIF([new Frame(4, 4), new Frame(4, 4), new Frame(4, 4)], 2); + const gif = new Animation([new Frame(4, 4), new Frame(4, 4), new Frame(4, 4)], {loopCount: 2}); assert(gif.slice(1).length === 2 && gif.slice(1).loopCount === 2, 'GIF.slice(start)'); assert(gif.slice(-1).length === 1, 'GIF.slice(negative)'); assert(gif.map(frame => frame.width).join() === '4,4,4', 'GIF.map'); @@ -108,7 +108,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // resizing GIFs scales partial frames proportionally { - const gif = new GIF([new Frame(10, 10), new Frame(4, 4, {duration: 100, xOffset: 6, yOffset: 6})]).resize(5, 5); + const gif = new Animation([new Frame(10, 10), new Frame(4, 4, {duration: 100, xOffset: 6, yOffset: 6})]).resize(5, 5); assert(gif.width === 5 && gif.height === 5, `GIF.resize: canvas is ${gif.width}x${gif.height}`); assert(gif[1].width === 2 && gif[1].xOffset === 3, 'GIF.resize: partial frame not scaled'); } @@ -327,8 +327,8 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // gif frame durations are rounded to centiseconds { // (different frames: identical ones are merged) - const gif = new GIF([new Frame(2, 2, {duration: 16}), new Frame(2, 2, {duration: 14}).fill(0xff0000ff)]); - const decoded = await GIF.decode(await gif.encode()); + const gif = new Animation([new Frame(2, 2, {duration: 16}), new Frame(2, 2, {duration: 14}).fill(0xff0000ff)]); + const decoded = await Animation.decode(await gif.encodeGIF()); assert(decoded[0].duration === 20 && decoded[1].duration === 10, `gif durations should round, got ${decoded[0].duration}, ${decoded[1].duration}`); } @@ -373,7 +373,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); let threw = false; try { frame.disposalMode = 0; } catch (e) { threw = e instanceof RangeError; } assert(threw && frame.disposalMode === 'previous', 'disposalMode should only take names'); - const decoded = await GIF.decode(await new GIF([new Frame(2, 2, {duration: 100, xOffset: 0, yOffset: 0, disposalMode: 'background'}), new Frame(2, 2).fill(0xff0000ff)]).encode()); + const decoded = await Animation.decode(await new Animation([new Frame(2, 2, {duration: 100, xOffset: 0, yOffset: 0, disposalMode: 'background'}), new Frame(2, 2).fill(0xff0000ff)]).encodeGIF()); assert(decoded.length === 2, 'gif with disposal modes should roundtrip'); } @@ -985,8 +985,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); await tooLarge('jpeg', () => Image.decode(jpeg)); await tooLarge('tiff samples', () => Image.decode(tiff)); await tooLarge('gif frames', () => Animation.decode(gif, {maxPixels: 4000 * 4000 * 10})); - await tooLarge('gif frames through GIF.decode', () => GIF.decode(gif, {maxPixels: 4000 * 4000 * 10})); - await tooLarge('maxPixels', () => Image.decode(png(100, 100), {maxPixels: 9999})); + await tooLarge('maxPixels', () => Image.decode(png(100, 100), {maxPixels: 9999})); // without a limit, the PNG gets as far as its data (which is too short) let error = null; From 474425ae0a0d03b7b5ba1a5201df9350ff327039 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 17:33:58 +0000 Subject: [PATCH 019/188] Animation holds its frames instead of extending Array --- CHANGELOG.md | 2 + src/animation.ts | 91 +++++++++++++++---------------------- tests/animation-class.js | 82 +++++++++++++++++++++++++++++++++ tests/animation-encoder.js | 2 +- tests/animation-optimize.js | 10 ++-- tests/animation.js | 10 ++-- tests/avif.js | 2 +- tests/gif.js | 10 ++-- tests/icc.js | 6 +-- tests/jxl.js | 10 ++-- tests/metadata.js | 2 +- tests/options-only.js | 4 +- tests/regressions.js | 24 +++++----- tests/svg.js | 2 +- tests/webp-lossy.js | 4 +- tests/webp.js | 4 +- 16 files changed, 166 insertions(+), 99 deletions(-) create mode 100644 tests/animation-class.js diff --git a/CHANGELOG.md b/CHANGELOG.md index fdc7bfae..79e08b96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ - **The `GIF` class is removed**: `new GIF(frames, loopCount)` is `new Animation(frames, { loopCount })`, `GIF.decode(data)` is `Animation.decode(data)` and `gif.encode(quality)` is `animation.encodeGIF({ quality })`. `Animation` decodes and encodes GIFs the same way it does APNGs and WebPs. +- **`Animation` holds its frames instead of extending `Array`**: `animation.frames` is the array the animation uses, and `animation[0]`, `animation.push(frame)`, `animation.map(...)` and the other array methods are `animation.frames[0]`, `animation.frames.push(frame)`, `animation.frames.map(...)`, and so on. `animation.slice(start, end)` is `new Animation(animation.frames.slice(start, end), { loopCount: animation.loopCount })`. `for (const frame of animation)`, `animation.length` and the methods of `Animation` work as before, and `Array.isArray(animation)` is `false`. + ## 3.20.1 ### Performance diff --git a/src/animation.ts b/src/animation.ts index 6441facf..0ca85ea2 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -112,13 +112,10 @@ export interface APNGEncodeOptions extends AnimationEncodeOptions { } /** - * An animation: frames ({@link Animation.frames}) that can be decoded from and encoded to GIF, APNG and WebP. - * - * The animation itself is an array of its frames too (`animation[0]`, `animation.map(...)`), which is deprecated: in 4.0 - * it won't extend `Array` anymore, use {@link Animation.frames} instead. Array methods like map and filter return - * plain arrays. + * An animation: frames ({@link Animation.frames}) that can be decoded from and encoded to GIF, APNG and WebP. It can be + * iterated (`for (const frame of animation)`), and its `length` is the number of frames. */ -export class Animation extends Array { +export class Animation { /** * How many times to repeat the animation (`Infinity` = forever, 0 = play once, 1 = play twice, ...). `-1` still * means forever, it will be removed in 4.0, when `Infinity` is what animations that loop forever have (animations @@ -126,6 +123,9 @@ export class Animation extends Array { */ loopCount: number; + /** @internal @ignore */ + private __frames__: Frame[] = []; + /** * Creates a new animation * @param frames The frames to create the animation from @@ -133,15 +133,9 @@ export class Animation extends Array { */ constructor(frames: Frame[], options?: AnimationOptions); constructor(frames: Frame[], options: AnimationOptions = {}) { - super(); checkOptions(options, 'Animation(frames, { loopCount: 2 })'); const { loopCount = -1 } = options; - // push instead of super(...frames), so a single frame isn't taken as a length - for (const frame of frames) this.push(frame); - - for (let i = 0; i < this.length; i++) - if (!(this[i] instanceof Frame)) - throw new TypeError(`Frame ${i} is not an instance of Frame`); + this.frames = frames; if (loopCount !== Infinity && (finite(loopCount, 'loop count') < -1 || !Number.isInteger(Number(loopCount)))) throw new RangeError(`Invalid loop count: ${loopCount}`); @@ -150,19 +144,12 @@ export class Animation extends Array { } /** - * @internal - * @ignore - */ - static override get [Symbol.species](): ArrayConstructor { - return Array; - } - - /** - * The animation's frames: an array that changes the animation when it's changed (e.g. `push()`, `splice()`, or - * setting an index), and can be replaced as a whole + * The animation's frames: the array the animation uses, so changing it (`push()`, `splice()`, setting an index) + * changes the animation. Assigning replaces them: the frames are copied into a new array, which is then the one + * the animation uses */ get frames(): Frame[] { - return this; + return this.__frames__; } /** @@ -170,54 +157,50 @@ export class Animation extends Array { * @param frames The new frames (copied into the animation, the array itself isn't kept) */ set frames(frames: Frame[]) { - frames = Array.from(frames); - frames.forEach((frame, i) => { - if (!(frame instanceof Frame)) throw new TypeError(`Frame ${i} is not an instance of Frame`); - }); - this.length = 0; - for (const frame of frames) this.push(frame); + const copy: Frame[] = []; + for (const frame of frames) { + if (!(frame instanceof Frame)) throw new TypeError(`Frame ${copy.length} is not an instance of Frame`); + copy.push(frame); + } + this.__frames__ = copy; + } + + /** The number of frames */ + get length(): number { + return this.__frames__.length; + } + + /** Iterates over the frames, `for (const frame of animation)` */ + [Symbol.iterator](): IterableIterator { + return this.__frames__[Symbol.iterator](); } /** The animation's width */ get width(): number { let max = 0; - for (const frame of this) max = Math.max(max, frame.width + frame.xOffset); + for (const frame of this.frames) max = Math.max(max, frame.width + frame.xOffset); return max; } /** The animation's height */ get height(): number { let max = 0; - for (const frame of this) max = Math.max(max, frame.height + frame.yOffset); + for (const frame of this.frames) max = Math.max(max, frame.height + frame.yOffset); return max; } /** The animation's duration (in ms) */ get duration(): number { let duration = 0; - for (const frame of this) duration += frame.duration; + for (const frame of this.frames) duration += frame.duration; return duration; } /** A short description for debugging, e.g. `Animation<64x64x1000ms>` */ - override toString(): string { + toString(): string { return `Animation<${this.width}x${this.height}x${this.duration}ms>`; } - /** - * Returns a new animation of the same class containing a range of this one's frames - * (same semantics as Array.prototype.slice) - * @param start The index of the first frame (negative counts from the end). Default: `0` - * @param end The index after the last frame (negative counts from the end). Default: the frame count - * @deprecated Use `new Animation(animation.frames.slice(start, end), { loopCount: animation.loopCount })`: in 4.0 animations - * aren't arrays anymore - * @group Deprecated - */ - override slice(start?: number, end?: number): this { - const Class = this.constructor as new (frames: Frame[], options: AnimationOptions) => this; - return new Class(Array.prototype.slice.call(this, start, end) as Frame[], { loopCount: this.loopCount }); - } - /** * Resizes the animation to the given dimensions, scaling every frame and its offsets proportionally. * Pass {@link ResizeToOptions} with only a width or a height to keep the aspect ratio. @@ -240,7 +223,7 @@ export class Animation extends Array { const height = givenHeight ?? originalHeight / originalWidth * width; const sx = width / originalWidth, sy = height / originalHeight; - for (const frame of this) { + for (const frame of this.frames) { // scale both edges so adjacent frames stay aligned const left = Math.round(frame.xOffset * sx), top = Math.round(frame.yOffset * sy); const right = Math.round((frame.xOffset + frame.width) * sx), bottom = Math.round((frame.yOffset + frame.height) * sy); @@ -258,7 +241,7 @@ export class Animation extends Array { * @ignore */ protected __validate__(): void { - for (const frame of this) + for (const frame of this.frames) if (!(frame instanceof Frame)) throw new Error('Animation contains invalid frames'); } @@ -276,7 +259,7 @@ export class Animation extends Array { this.__validate__(); const frames = optimize ? this.__optimized__({ replace: false, even: false, alpha: 'frame', palette: 256, cover: true }).map(f => ({ ...f, dispose: f.dispose ? 3 : 1 })) - : Array.from(this, frame => ({ + : Array.from(this.frames, frame => ({ x: frame.xOffset, y: frame.yOffset, width: frame.width, @@ -336,12 +319,12 @@ export class Animation extends Array { __webpFrames__(): webp.WebPEncodeFrame[] { const width = this.width, height = this.height; // 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))); + const fullFrames = this.frames.some(f => f.disposalMode === 'previous' || (f.disposalMode === 'background' && (f.xOffset & 1 || f.yOffset & 1))); if (fullFrames) return png.compositeAnimation({ width, height, frames: this.__apngFrames__() }) .map(c => ({ x: 0, y: 0, width, height, duration: c.duration, dispose: true, pixels: c.pixels })); - return Array.from(this, frame => { + return Array.from(this.frames, frame => { let { xOffset: x, yOffset: y, width: w, height: h } = frame; let pixels = new Uint8Array(frame.bitmap.buffer, frame.bitmap.byteOffset, frame.bitmap.byteLength); // odd offsets: one transparent pixel more on the left/top (blending makes it invisible) @@ -372,7 +355,7 @@ export class Animation extends Array { */ __apngFrames__(): png.APNGFrame[] { const dispose = [0, 0, 2, 1]; - return Array.from(this, frame => ({ + return Array.from(this.frames, frame => ({ x: frame.xOffset, y: frame.yOffset, width: frame.width, diff --git a/tests/animation-class.js b/tests/animation-class.js new file mode 100644 index 00000000..ec8edac4 --- /dev/null +++ b/tests/animation-class.js @@ -0,0 +1,82 @@ +const {Image, Frame, Animation} = require('..'); +const lib = require('..'); + +const panic = message => { + console.error(message); + process.exit(1); +}; +const throwsA = (fn, type, label) => { + let error = null; + try { fn(); } catch (e) { error = e; } + if (!(error instanceof type)) panic(`${label}: should throw a ${type.name}, got ${error}`); + return error; +}; + +const a = new Frame(4, 4, {duration: 10}).fill(0xff0000ff); +const b = new Frame(2, 2, {duration: 20, xOffset: 1, yOffset: 1}).fill(0x00ff00ff); +const c = new Frame(3, 3, {duration: 30}).fill(0x0000ffff); + +// an animation holds its frames: it is not an array +const animation = new Animation([a, b]); +if (Array.isArray(animation)) panic('Array.isArray(animation) should be false'); +if (animation instanceof Array) panic('an animation should not be an Array'); +if (Object.prototype.toString.call(animation) !== '[object Object]') panic(`animation tag: ${Object.prototype.toString.call(animation)}`); + +// the removed Array members are gone: the animation and its prototype +for (const name of ['push', 'pop', 'map', 'filter', 'slice', 'forEach', 'reduce', 'some', 'every', 'find', 'splice', 'concat', 'join']) + if (animation[name] !== undefined || Animation.prototype[name] !== undefined) panic(`Animation.${name} should be gone`); +if (animation[0] !== undefined) panic('animation[0] should be gone: use animation.frames[0]'); +if (Animation[Symbol.species] !== undefined) panic('Animation[Symbol.species] should be gone'); +if (lib.GIF !== undefined) panic('the GIF export should be gone'); + +// frames: the array the animation uses, live and assignable +if (animation.frames[0] !== a || animation.frames[1] !== b) panic('frames should be the frames'); +animation.frames.push(c); +if (animation.length !== 3 || animation.duration !== 60) panic('pushing to frames should add a frame'); +animation.frames.splice(1, 1); +if (animation.length !== 2 || animation.frames[1] !== c) panic('splicing frames should remove a frame'); +animation.frames[0] = b; +if (animation.frames[0] !== b) panic('setting an index of frames should replace the frame'); +animation.frames = [a, c]; +if (animation.length !== 2 || animation.frames[0] !== a || animation.frames[1] !== c) panic('assigning frames should replace them'); + +// assigning copies: the array given isn't the one the animation uses, and a bad frame changes nothing +const list = [b, c]; +animation.frames = list; +if (animation.frames === list) panic('assigned frames should be copied into the animation'); +list.push(a); +if (animation.length !== 2) panic('changing the array given to frames should not change the animation'); +throwsA(() => { animation.frames = [a, {}]; }, TypeError, 'assigning a non-frame'); +if (animation.length !== 2 || animation.frames[0] !== b) panic('a rejected assignment should leave the frames untouched'); + +// length is read-only and counts the frames +if (Reflect.set(animation, 'length', 5) !== false || animation.length !== 2) panic('length should be read-only'); +if (new Animation([]).length !== 0) panic('an empty animation has no frames'); + +// iteration +const seen = []; +for (const frame of animation) seen.push(frame); +if (seen.length !== 2 || seen[0] !== b || seen[1] !== c) panic('for...of should iterate the frames'); +if ([...animation].length !== 2 || Array.from(animation)[1] !== c) panic('spread and Array.from should iterate the frames'); +const iterator = animation[Symbol.iterator](); +if (iterator.next().value !== b || iterator.next().value !== c || !iterator.next().done) panic('the iterator should end after the frames'); + +// the constructor takes a copy of the frames, validates them and the options +const list2 = [a]; +const copy = new Animation(list2); +list2.push(b); +if (copy.length !== 1) panic('the constructor should copy the frames'); +throwsA(() => new Animation([a, 'frame']), TypeError, 'a string as a frame'); +throwsA(() => new Animation([{width: 4, height: 4}]), TypeError, 'an image-like object as a frame'); +throwsA(() => new Animation(5), TypeError, 'a number as the frames'); +throwsA(() => new Animation(null), TypeError, 'null as the frames'); +throwsA(() => new Animation([a], 2), TypeError, 'a loop count instead of options'); +throwsA(() => new Animation([a], {loopCount: 2.5}), RangeError, 'a fractional loop count'); +if (new Animation([a], {loopCount: 2}).loopCount !== 2) panic('loopCount option not applied'); + +// the size, duration and text of the animation come from its frames +const sized = new Animation([a, b]); +if (sized.width !== 4 || sized.height !== 4 || sized.duration !== 30) panic(`size ${sized.width}x${sized.height}, ${sized.duration} ms`); +if (sized.toString() !== 'Animation<4x4x30ms>') panic(`toString: ${sized.toString()}`); +const first = new Image(2, 2); +if (new Animation([Frame.from(first, {duration: 5})]).frames[0].duration !== 5) panic('Frame.from frames'); diff --git a/tests/animation-encoder.js b/tests/animation-encoder.js index a61403ff..3a66ff9c 100644 --- a/tests/animation-encoder.js +++ b/tests/animation-encoder.js @@ -39,7 +39,7 @@ const metadata = {creationTime: 0, software: ''}; const a = await Animation.decode(file), b = await Animation.decode(expected); const differs = (f, g) => options.quality === undefined ? f.bitmap.some((v, k) => Math.abs(v - g.bitmap[k]) > 1) : f.bitmap.reduce((sum, v, k) => sum + Math.abs(v - g.bitmap[k]), 0) / f.bitmap.length > 4; - if (a.length !== b.length || a.some((f, i) => f.duration !== b[i].duration || differs(f, b[i]))) + if (a.length !== b.length || a.frames.some((f, i) => f.duration !== b.frames[i].duration || differs(f, b.frames[i]))) panic(`${name} ${format} ${JSON.stringify(options)}: doesn't show the same animation`); } diff --git a/tests/animation-optimize.js b/tests/animation-optimize.js index 3d566f03..cd10dd3b 100644 --- a/tests/animation-optimize.js +++ b/tests/animation-optimize.js @@ -101,7 +101,7 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { const animation = new Animation([new Frame(10, 10, {duration: 40}), dot, dot.clone(), Frame.from(dot, {duration: 25}), new Frame(10, 10, {duration: 30})]); await check('empty and identical frames', animation); const decoded = await Animation.decode(await animation.encodeAPNG()); - if (decoded.length !== 2 || decoded[0].duration !== 40 || decoded[1].duration !== 255) panic(`identical frames: ${decoded.length} frames, durations ${Array.from(decoded, f => f.duration)}`); + if (decoded.length !== 2 || decoded.frames[0].duration !== 40 || decoded.frames[1].duration !== 255) panic(`identical frames: ${decoded.length} frames, durations ${Array.from(decoded.frames, f => f.duration)}`); } // an opaque animation whose frames are smaller sparse, but not by enough to pay for an alpha channel in every APNG @@ -131,8 +131,8 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { }; const animation = new Animation([Frame.from(grid(0), {duration: 100}), Frame.from(grid(1), {duration: 100})]); const decoded = await Animation.decode(await animation.encodeGIF()); - decoded.forEach((frame, i) => { - if (frame.bitmap.some((v, k) => v !== animation[i].bitmap[k])) panic(`palette: GIF frame ${i} lost colors`); + decoded.frames.forEach((frame, i) => { + if (frame.bitmap.some((v, k) => v !== animation.frames[i].bitmap[k])) panic(`palette: GIF frame ${i} lost colors`); }); } @@ -144,7 +144,7 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { const plain = await Animation.decode(await animation[`encode${format}`]({optimize: false})); if (plain.length !== 4) panic(`optimize false: ${format} has ${plain.length} frames instead of 4`); const expected = await composite(animation); - if (format === 'APNG' && plain.some((f, i) => f.bitmap.some((v, k) => v !== expected[i].bitmap[k]))) panic('optimize false: APNG frames differ'); + if (format === 'APNG' && plain.frames.some((f, i) => f.bitmap.some((v, k) => v !== expected.frames[i].bitmap[k]))) panic('optimize false: APNG frames differ'); } await check('optimize false', animation); } @@ -197,7 +197,7 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { const png = await import('../dist/codecs/png.js'); const shown = png.compositeAnimation({width: 100, height: 60, frames: frames.map(f => ({...f, dispose: f.dispose ? 1 : 0}))}); shown.forEach(({pixels}, i) => { - const expected = animation[i].bitmap; + const expected = animation.frames[i].bitmap; for (let k = 0; k < pixels.length; k++) if (Math.abs(pixels[k] - expected[k]) > tolerance) panic(`lossy WebP: frame ${i} differs by ${Math.abs(pixels[k] - expected[k])} at pixel ${k >> 2}`); }); diff --git a/tests/animation.js b/tests/animation.js index a52dbe5b..27d5af4d 100644 --- a/tests/animation.js +++ b/tests/animation.js @@ -35,8 +35,8 @@ const square = (color, w, h) => new Image(w, h).fill((x, y) => (x + y) % 5 === 0 const bytes = await animation[`encode${format}`](); const back = await Animation.decode(bytes); if (back.length !== 4 || back.loopCount !== 2) panic(`${format}: wrong frame or loop count`); - if (back.map(f => f.duration).join() !== '100,50,70,30') panic(`${format}: wrong durations`); - back.forEach((frame, i) => { + if (back.frames.map(f => f.duration).join() !== '100,50,70,30') panic(`${format}: wrong durations`); + back.frames.forEach((frame, i) => { if (!Buffer.from(frame.bitmap).equals(Buffer.from(reference[i].bitmap))) panic(`${format} variant ${variant}: frame ${i} differs`); }); } @@ -53,17 +53,17 @@ const square = (color, w, h) => new Image(w, h).fill((x, y) => (x + y) % 5 === 0 if (single.length !== 1 || single.width !== 8) panic('Animation.decode should accept still images'); const gif = await Animation.decode(await new Animation([Frame.from(still)]).encodeGIF()); if (!(gif instanceof Animation) || gif.length !== 1) panic('Animation.decode should decode GIFs'); - if (!(gif.slice(0, 1) instanceof Animation)) panic('slice should keep the class'); + if (!(new Animation(gif.frames.slice(0, 1), {loopCount: gif.loopCount}) instanceof Animation)) panic('a new animation of some frames'); // frames: the animation's frames, changing them changes the animation { const animation = new Animation([Frame.from(still)]); - if (animation.frames.length !== 1 || animation.frames[0] !== animation[0]) panic('frames should hold the frames'); + if (animation.frames.length !== 1) panic('frames should hold the frames'); animation.frames.push(Frame.from(still, {duration: 50})); if (animation.length !== 2 || animation.duration !== 150) panic('frames.push should add a frame'); if (!Array.isArray(animation.frames.map(f => f.duration))) panic('frames.map should return an array'); animation.frames = animation.frames.filter(f => f.duration === 50); - if (animation.length !== 1 || animation[0].duration !== 50) panic('setting frames should replace them'); + if (animation.length !== 1 || animation.frames[0].duration !== 50) panic('setting frames should replace them'); let threw = false; try { animation.frames = [still]; } catch { threw = true; } if (!threw || animation.length !== 1) panic('frames should only take frames'); diff --git a/tests/avif.js b/tests/avif.js index 25407032..fd18d316 100644 --- a/tests/avif.js +++ b/tests/avif.js @@ -68,7 +68,7 @@ const references = { // image sequences decode as their first frame const animation = await Animation.decode(await fs.readFile('./tests/avif/sequence.avif')); - if (animation.length !== 1 || sha(animation[0]) !== references.sequence) panic('sequence: Animation.decode should give its first frame'); + if (animation.length !== 1 || sha(animation.frames[0]) !== references.sequence) panic('sequence: Animation.decode should give its first frame'); // invalid data throws const binary = await fs.readFile('./tests/avif/yuv420.avif'); diff --git a/tests/gif.js b/tests/gif.js index fb8fc2d9..4dd8db13 100644 --- a/tests/gif.js +++ b/tests/gif.js @@ -31,7 +31,7 @@ const panic = message => { panic('decode: incorrect frame count'); for (let i = 0; i < gif.length; i++) { - if (gif[i].bitmap.length !== decoded[i].bitmap.length) + if (gif.frames[i].bitmap.length !== decoded.frames[i].bitmap.length) panic('decode: incorrect frame bitmap length'); } @@ -44,9 +44,9 @@ const panic = message => { // frames with <= 256 colors survive a roundtrip losslessly const decoded = await Animation.decode(await gif.encodeGIF()); for (let i = 0; i < gif.length; i++) - if (!Buffer.from(decoded[i].bitmap).equals(Buffer.from(gif[i].bitmap))) + if (!Buffer.from(decoded.frames[i].bitmap).equals(Buffer.from(gif.frames[i].bitmap))) panic(`roundtrip: frame ${i} differs`); - if (decoded[0].duration !== gif[0].duration) + if (decoded.frames[0].duration !== gif.frames[0].duration) panic('roundtrip: incorrect duration'); if (decoded.loopCount !== -1) panic('roundtrip: incorrect loop count'); @@ -74,7 +74,7 @@ const panic = message => { for (let y = 0; y < 4; y++) for (let x = 0; x < 4; x++) { const want = pixels[y * 4 + x]; - const got = decoded[frame].getPixelAt(x, y); + const got = decoded.frames[frame].getPixelAt(x, y); if (want === 'B' ? got !== black : want === 'W' ? got !== white : got !== 0) panic(`disposal: frame ${frame} pixel ${x},${y} is ${got.toString(16)}`); } @@ -88,7 +88,7 @@ const panic = message => { // interlaced: written by Pillow (scripts/gif-fixtures.py), the sha256 of its RGBA pixels as Pillow decodes them { - const [frame] = await Animation.decode(await fs.readFile('./tests/gifs/interlaced.gif')); + const [frame] = (await Animation.decode(await fs.readFile('./tests/gifs/interlaced.gif'))).frames; if (frame.width !== 80 || frame.height !== 37) panic('interlaced: dimensions don\'t match'); if (crypto.createHash('sha256').update(new Uint8Array(frame.bitmap)).digest('hex') !== '854e5e98f2124a6009d5bc28748772ede25af244a2503021ef4f52e323309d46') panic('interlaced: pixels don\'t match'); diff --git a/tests/icc.js b/tests/icc.js index 4b84c522..c73439d5 100644 --- a/tests/icc.js +++ b/tests/icc.js @@ -53,11 +53,11 @@ const difference = (a, b) => { const animation = await Animation.decode(await fs.readFile('./tests/icc/display-p3-animated.webp')); for (const i of [0, 1]) { const ref = await Image.decode(await fs.readFile(`./tests/icc/display-p3-animated-${i + 1}.ref.png`)); - if (difference(animation[i], ref) > 1) panic(`animated WebP frame ${i + 1}: differs from LittleCMS`); + if (difference(animation.frames[i], ref) > 1) panic(`animated WebP frame ${i + 1}: differs from LittleCMS`); } // (JPEG XLs through Animation.decode too) const jxlFrames = await Animation.decode(await fs.readFile('./tests/icc/display-p3.jxl')); - if (difference(jxlFrames[0], await Image.decode(await fs.readFile('./tests/icc/display-p3.ref.png'))) > 1) panic('JPEG XL through Animation.decode: differs from LittleCMS'); + if (difference(jxlFrames.frames[0], await Image.decode(await fs.readFile('./tests/icc/display-p3.ref.png'))) > 1) panic('JPEG XL through Animation.decode: differs from LittleCMS'); // lossless JPEG XLs in an enumerated color space (no ICC profile, like cjxl writes Display P3): converted to sRGB // like an embedded profile. The Display P3 values, losslessly encoded, with the image header's color encoding @@ -72,7 +72,7 @@ const difference = (a, b) => { const p3Decoded = await Image.decode(enumerated); if (difference(p3Decoded, p3Ref) > 1) panic(`enumerated Display P3 JPEG XL: differs from LittleCMS by ${difference(p3Decoded, p3Ref)}`); if (difference(await Image.decode(enumerated, {applyColorProfile: false}), p3) !== 0) panic('enumerated Display P3 JPEG XL, applyColorProfile: false: should keep the stored colors'); - if (difference((await Animation.decode(enumerated))[0], p3Ref) > 1) panic('enumerated Display P3 JPEG XL through Animation.decode: differs from LittleCMS'); + if (difference((await Animation.decode(enumerated)).frames[0], p3Ref) > 1) panic('enumerated Display P3 JPEG XL through Animation.decode: differs from LittleCMS'); })(); // the codestream of a JPEG XL in the container (our encoder's: one jxlc box) diff --git a/tests/jxl.js b/tests/jxl.js index 05193578..260208a9 100644 --- a/tests/jxl.js +++ b/tests/jxl.js @@ -42,9 +42,9 @@ const difference = (bitmap, width, height, reference, step = 1, premultiplied = const animation = await Animation.decode(binary); const references = await Animation.decode(await fs.readFile(`./tests/jxl/${name}.png`)); if (animation.length !== references.length) panic(`${name}: ${animation.length} frames instead of ${references.length}`); - animation.forEach((frame, i) => { - if (frame.duration !== references[i].duration) panic(`${name}: frame ${i} lasts ${frame.duration} ms instead of ${references[i].duration}`); - max = Math.max(max, difference(frame.bitmap, frame.width, frame.height, references[i])); + animation.frames.forEach((frame, i) => { + if (frame.duration !== references.frames[i].duration) panic(`${name}: frame ${i} lasts ${frame.duration} ms instead of ${references.frames[i].duration}`); + max = Math.max(max, difference(frame.bitmap, frame.width, frame.height, references.frames[i])); }); } if (max > (premultiplied ? 2 : 1)) panic(`${name}: differs by ${max} from djxl's`); @@ -54,7 +54,7 @@ const difference = (bitmap, width, height, reference, step = 1, premultiplied = const first = await Animation.decode(await fs.readFile('./tests/jxl/animation.jxl'), {onlyFirstFrame: true}); if (first.length !== 1) panic(`onlyFirstFrame: ${first.length} frames`); const still = await Animation.decode(await fs.readFile('./tests/jxl/lossy.jxl')); - if (still.length !== 1 || still[0].width !== 64) panic('a still JPEG XL as an animation'); + if (still.length !== 1 || still.frames[0].width !== 64) panic('a still JPEG XL as an animation'); // truncated or corrupted data throws const lossy = await fs.readFile('./tests/jxl/lossy.jxl'); @@ -75,7 +75,7 @@ const difference = (bitmap, width, height, reference, step = 1, premultiplied = // (not in shared memory, which some APIs reject) if (four.bitmap.buffer instanceof SharedArrayBuffer) panic('threads: the bitmap is in shared memory'); const animation = await Animation.decode(data, {threads: 4}); - if (Buffer.compare(Buffer.from(animation[0].bitmap), Buffer.from(one.bitmap))) panic('threads, Animation.decode: different pixels'); + if (Buffer.compare(Buffer.from(animation.frames[0].bitmap), Buffer.from(one.bitmap))) panic('threads, Animation.decode: different pixels'); } for (const threads of [0, 1.5]) if (!await Image.decode(lossy, {threads}).then(() => false, e => e instanceof RangeError)) panic(`threads ${threads} should throw`); })(); diff --git a/tests/metadata.js b/tests/metadata.js index 4926bc4a..c0d6eda0 100644 --- a/tests/metadata.js +++ b/tests/metadata.js @@ -33,7 +33,7 @@ const latin1 = (data, text) => Buffer.from(data).includes(Buffer.from(text, 'lat 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); + const decoded = /gif|apng|animated/.test(format) ? (await Animation.decode(tagged)).frames[0] : await Image.decode(tagged); if (decoded.width !== 8 || decoded.height !== 4) panic(`${format}: doesn't decode with metadata`); } diff --git a/tests/options-only.js b/tests/options-only.js index a0832e7e..d0328fed 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -38,7 +38,7 @@ const sameImage = (image, reference, label) => { if (frame.width !== 20 || frame.height !== 12) panic(`Frame.resize(10, 6, ${label}): the frame should keep its size`); const animation = new Animation([new Frame(20, 12)]); await rejects(() => animation.resize(10, 6, mode), `Animation.resize(10, 6, ${label})`); - if (animation[0].width !== 20 || animation[0].height !== 12) panic(`Animation.resize(10, 6, ${label}): the frames should keep their size`); + if (animation.frames[0].width !== 20 || animation.frames[0].height !== 12) panic(`Animation.resize(10, 6, ${label}): the frames should keep their size`); } // the image operations take their values in the options object only @@ -141,5 +141,5 @@ const sameImage = (image, reference, label) => { if (photo.clone().fit(10, 10, {mode: ResizeModes.AREA}).height !== 10) panic('fit(10, 10, { mode })'); if (photo.clone().cover(10, 10, {mode: ResizeModes.AREA}).width !== 10) panic('cover(10, 10, { mode })'); if (new Frame(20, 12).resize(10, 6, {mode: ResizeModes.AREA}).width !== 10) panic('Frame.resize(10, 6, { mode })'); - if (new Animation([new Frame(20, 12)]).resize(10, 6, {mode: ResizeModes.AREA})[0].width !== 10) panic('Animation.resize(10, 6, { mode })'); + if (new Animation([new Frame(20, 12)]).resize(10, 6, {mode: ResizeModes.AREA}).frames[0].width !== 10) panic('Animation.resize(10, 6, { mode })'); })().catch(error => panic(error.stack || String(error))); diff --git a/tests/regressions.js b/tests/regressions.js index 175ef0f7..0d06ffbe 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -97,20 +97,20 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); assert(resized === frame && frame.duration === 500 && frame.xOffset === 1 && frame.yOffset === 2, 'Frame.resize lost metadata'); } - // GIFs behave like arrays + // the frames are the array: the animation's frames have the array methods, the animation itself none { const gif = new Animation([new Frame(4, 4), new Frame(4, 4), new Frame(4, 4)], {loopCount: 2}); - assert(gif.slice(1).length === 2 && gif.slice(1).loopCount === 2, 'GIF.slice(start)'); - assert(gif.slice(-1).length === 1, 'GIF.slice(negative)'); - assert(gif.map(frame => frame.width).join() === '4,4,4', 'GIF.map'); - assert(gif.filter(() => true).length === 3, 'GIF.filter'); + assert(new Animation(gif.frames.slice(1), {loopCount: gif.loopCount}).length === 2, 'frames.slice(start)'); + assert(gif.frames.slice(-1).length === 1, 'frames.slice(negative)'); + assert(gif.frames.map(frame => frame.width).join() === '4,4,4', 'frames.map'); + assert(gif.frames.filter(() => true).length === 3, 'frames.filter'); } // resizing GIFs scales partial frames proportionally { const gif = new Animation([new Frame(10, 10), new Frame(4, 4, {duration: 100, xOffset: 6, yOffset: 6})]).resize(5, 5); assert(gif.width === 5 && gif.height === 5, `GIF.resize: canvas is ${gif.width}x${gif.height}`); - assert(gif[1].width === 2 && gif[1].xOffset === 3, 'GIF.resize: partial frame not scaled'); + assert(gif.frames[1].width === 2 && gif.frames[1].xOffset === 3, 'GIF.resize: partial frame not scaled'); } // coordinates are 0-based everywhere @@ -329,7 +329,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // (different frames: identical ones are merged) const gif = new Animation([new Frame(2, 2, {duration: 16}), new Frame(2, 2, {duration: 14}).fill(0xff0000ff)]); const decoded = await Animation.decode(await gif.encodeGIF()); - assert(decoded[0].duration === 20 && decoded[1].duration === 10, `gif durations should round, got ${decoded[0].duration}, ${decoded[1].duration}`); + assert(decoded.frames[0].duration === 20 && decoded.frames[1].duration === 10, `gif durations should round, got ${decoded.frames[0].duration}, ${decoded.frames[1].duration}`); } // jpegs are rotated as their exif orientation says (fixtures written by Pillow, checked against ImageOps.exif_transpose) @@ -415,7 +415,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const other = new Animation([Frame.from(photo.clone(), {duration: 100}), Frame.from(photo.clone().invert(), {duration: 100})]); animation.resize({height: 4}); other.resize(6, 4); - assert(animation.width === 6 && animation.height === 4 && [...animation].every((f, i) => same(f, other[i])), 'Animation.resize({height})'); + assert(animation.width === 6 && animation.height === 4 && [...animation].every((f, i) => same(f, other.frames[i])), 'Animation.resize({height})'); } // coordinates() and pixels() yield every pixel row by row @@ -514,7 +514,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const oriented = await Animation.decode(data, options); assert(oriented.width === 2 && oriented.height === 3 && oriented.length === 2, `${name}: rotated (${JSON.stringify(options)})`); for (let i = 0; i < 2; i++) - assert(pixel(oriented[i], 1, 0).join() === pixel(stored[i], 0, 0).join() && pixel(oriented[i], 0, 2).join() === pixel(stored[i], 2, 1).join(), `${name}: frame ${i} not oriented like Image.decode`); + assert(pixel(oriented.frames[i], 1, 0).join() === pixel(stored.frames[i], 0, 0).join() && pixel(oriented.frames[i], 0, 2).join() === pixel(stored.frames[i], 2, 1).join(), `${name}: frame ${i} not oriented like Image.decode`); } } // (without an orientation tag nothing changes) @@ -546,9 +546,9 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const plain = await Animation.decode(container, {gainMap: false}); const withMap = await Animation.decode(container); assert(plain.length > 1 && withMap.length === plain.length, 'JPEG XL animation with a jhgm box decodes'); - assert(plain.every(f => f.gainMap === null) && (await Animation.decode(codestream)).every(f => f.gainMap === null), 'JPEG XL: gainMap false, or no jhgm box'); - assert(withMap.every(f => f.gainMap && f.gainMap.width === 4 && f.gainMap.gainMapMax[0] === 2), 'JPEG XL: every frame has the gain map'); - assert(withMap[0].gainMap !== withMap[1].gainMap && withMap.every((f, i) => Buffer.from(f.bitmap).equals(Buffer.from(plain[i].bitmap))), 'JPEG XL: the frames are the same with the map, with maps of their own'); + assert(plain.frames.every(f => f.gainMap === null) && (await Animation.decode(codestream)).frames.every(f => f.gainMap === null), 'JPEG XL: gainMap false, or no jhgm box'); + assert(withMap.frames.every(f => f.gainMap && f.gainMap.width === 4 && f.gainMap.gainMapMax[0] === 2), 'JPEG XL: every frame has the gain map'); + assert(withMap.frames[0].gainMap !== withMap.frames[1].gainMap && withMap.frames.every((f, i) => Buffer.from(f.bitmap).equals(Buffer.from(plain.frames[i].bitmap))), 'JPEG XL: the frames are the same with the map, with maps of their own'); // toneMapping is checked let threw = false; diff --git a/tests/svg.js b/tests/svg.js index 51aa15ca..4d3ce8a1 100644 --- a/tests/svg.js +++ b/tests/svg.js @@ -55,7 +55,7 @@ const panic = message => { const cases = [ ['image/png', await source.encodePNG({compression: 1}), source], ['image/webp', await source.encodeWebP(), source], - ['image/gif', gif, (await Animation.decode(gif))[0]], + ['image/gif', gif, (await Animation.decode(gif)).frames[0]], ['image/jpeg', jpeg, await Image.decode(jpeg)], ['image/svg+xml', require('zlib').gzipSync(rect), await Image.renderSVG(rect)] ]; diff --git a/tests/webp-lossy.js b/tests/webp-lossy.js index 48e9e18e..56f7d798 100644 --- a/tests/webp-lossy.js +++ b/tests/webp-lossy.js @@ -150,8 +150,8 @@ const alphaReferences = { // (the last frame is half transparent: it's blended over the one before) const lossless = await Animation.decode(await animation.encodeWebP()); for (let f = 0; f < 3; f++) { - const [r, g, b, a] = Colors.toRGBA(decoded[f].getPixelAt(1, 1)); - const [er, eg, eb, ea] = Colors.toRGBA(lossless[f].getPixelAt(1, 1)); + const [r, g, b, a] = Colors.toRGBA(decoded.frames[f].getPixelAt(1, 1)); + const [er, eg, eb, ea] = Colors.toRGBA(lossless.frames[f].getPixelAt(1, 1)); if (Math.abs(r - er) + Math.abs(g - eg) + Math.abs(b - eb) > 12 || a !== ea) panic(`lossy animation: frame ${f} decodes to ${[r, g, b, a]}`); } if (!await animation.encodeWebP({quality: 200}).then(() => false, e => e instanceof RangeError)) panic('animation quality 200 should throw'); diff --git a/tests/webp.js b/tests/webp.js index 2396e80d..56324a13 100644 --- a/tests/webp.js +++ b/tests/webp.js @@ -47,8 +47,8 @@ const animated = [ const animation = await Animation.decode(data); if (!(await decode(data) instanceof Animation)) panic('decode() should return an Animation for animated WebPs'); if (animation.length !== 4 || animation.loopCount !== -1) panic('animated WebP: wrong frame or loop count'); - if (animation.map(f => f.duration).join() !== '80,120,80,200') panic('animated WebP: wrong durations'); - animation.forEach((frame, i) => { + if (animation.frames.map(f => f.duration).join() !== '80,120,80,200') panic('animated WebP: wrong durations'); + animation.frames.forEach((frame, i) => { if (sha256(frame.bitmap) !== animated[i]) panic(`animated WebP: frame ${i} doesn't match libwebp`); }); From 421450296f5c8628b40f1cf3cee65a1f1c14aded Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 17:38:16 +0000 Subject: [PATCH 020/188] loop counts are Infinity or a count from 0 --- CHANGELOG.md | 2 + src/animation-encoder.ts | 30 ++++++------ src/animation.ts | 53 +++++++++++++-------- src/codecs/gif.ts | 10 ++-- src/util/options.ts | 11 +++++ tests/animation.js | 4 +- tests/gif.js | 2 +- tests/loop-count.js | 100 +++++++++++++++++++++++++++++++++++++++ tests/options-only.js | 2 +- tests/regressions.js | 10 ++-- tests/webp.js | 2 +- 11 files changed, 176 insertions(+), 50 deletions(-) create mode 100644 tests/loop-count.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e08b96..503cedcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ - **`Animation` holds its frames instead of extending `Array`**: `animation.frames` is the array the animation uses, and `animation[0]`, `animation.push(frame)`, `animation.map(...)` and the other array methods are `animation.frames[0]`, `animation.frames.push(frame)`, `animation.frames.map(...)`, and so on. `animation.slice(start, end)` is `new Animation(animation.frames.slice(start, end), { loopCount: animation.loopCount })`. `for (const frame of animation)`, `animation.length` and the methods of `Animation` work as before, and `Array.isArray(animation)` is `false`. +- **Loop counts are `Infinity` (forever, the default) or an integer from 0**: `loopCount: -1` is `loopCount: Infinity`, and the encoders and the decoded animations use `Infinity` where they used `-1`. `-1` now throws a `RangeError` like the other negative numbers, `NaN` and fractions do, and numeric strings, `null` and booleans throw a `TypeError`. The check applies to `new Animation(frames, { loopCount })`, `animation.loopCount = ...` (which leaves the count as it was when it throws) and the encoders (`Animation.gifEncoder()`, `webpEncoder()` and `apngEncoder()`). Finite counts are the same as before: `loopCount: 2` still plays three times. + ## 3.20.1 ### Performance diff --git a/src/animation-encoder.ts b/src/animation-encoder.ts index 2b87d581..5db09c2e 100644 --- a/src/animation-encoder.ts +++ b/src/animation-encoder.ts @@ -6,11 +6,11 @@ import { Frame, FrameOptions } from './frame.ts'; import { Optimizer, Compositor, InputFrame, OptimizeOptions, OptimizedFrame, coverCanvas, fillUnchanged } from './util/optimize.ts'; import { compressAsync, decompressAsync } from './codecs/zlib.ts'; import { resolveMetadata, pngText, exifData, gifComments } from './metadata.ts'; -import { checkOptions, finite, compressionLevel } from './util/options.ts'; +import { checkOptions, finite, compressionLevel, loopCount } from './util/options.ts'; import type { AnimationOptions, WebPAnimationEncodeOptions, GIFEncodeOptions, APNGEncodeOptions } from './animation.ts'; -// (ImageScript, like GIF, counts repetitions: -1 forever, 0 play once; APNG and WebP count plays, 0 forever) -const toPlays = (loopCount: number): number => loopCount < 0 || loopCount === Infinity ? 0 : loopCount + 1; +// (the loop count is how many times the animation starts over, Infinity forever; APNG and WebP count plays, 0 forever) +const toPlays = (count: number): number => count === Infinity ? 0 : count + 1; // a format's part: takes the frames in order once they can't change anymore, then makes the file interface Format { @@ -94,8 +94,9 @@ export class AnimationEncoder { } /** @internal */ - static __webp__(width: number, height: number, { loopCount = -1, metadata = {}, optimize = true, quality, effort = 2, sharpYuv = false, alphaQuality = 100 }: WebPAnimationEncodeOptions & AnimationOptions): AnimationEncoder { - canvas(width, height, loopCount); + static __webp__(width: number, height: number, { loopCount: count = Infinity, metadata = {}, optimize = true, quality, effort = 2, sharpYuv = false, alphaQuality = 100 }: WebPAnimationEncodeOptions & AnimationOptions): AnimationEncoder { + const loops = loopCount(count); + canvas(width, height); if (width > 1 << 24 || height > 1 << 24) throw new RangeError('WebP: canvas too large'); const lossy = webp.lossyOptions(quality, effort, sharpYuv, alphaQuality); const resolved = resolveMetadata(metadata), chunks: Uint8Array[] = []; @@ -118,14 +119,15 @@ export class AnimationEncoder { if (full) return add({ x: 0, y: 0, width, height, duration: input.duration, dispose: true, blend: false, pixels: shown }); await add(webpFrame(input)); }, - file: () => webp.animationFile(chunks, width, height, toPlays(loopCount), alpha, exifData(resolved)) + file: () => webp.animationFile(chunks, width, height, toPlays(loops), alpha, exifData(resolved)) }, optimize); } /** @internal */ - static __gif__(width: number, height: number, { loopCount = -1, metadata = {}, optimize = true, quality: qualityOption = 95 }: GIFEncodeOptions & AnimationOptions): AnimationEncoder { + static __gif__(width: number, height: number, { loopCount: count = Infinity, metadata = {}, optimize = true, quality: qualityOption = 95 }: GIFEncodeOptions & AnimationOptions): AnimationEncoder { const quality = finite(qualityOption, 'quality'); - canvas(width, height, loopCount); + const loops = loopCount(count); + canvas(width, height); const comments = gifComments(resolveMetadata(metadata)); // (the first frame is encoded last: it needs a transparent index if any frame restores the background) let first: gifCodec.GIFFrame | null = null, restores = false; @@ -144,15 +146,16 @@ export class AnimationEncoder { }, file() { const f = first as gifCodec.GIFFrame; - return concat([gifCodec.header(width, height, loopCount, comments), gifCodec.encodeFrame(f, quality, f.dispose === 3 || restores), ...rest, new Uint8Array([0x3b])]); + return concat([gifCodec.header(width, height, loops, comments), gifCodec.encodeFrame(f, quality, f.dispose === 3 || restores), ...rest, new Uint8Array([0x3b])]); } }, optimize); } /** @internal */ - static __apng__(width: number, height: number, { loopCount = -1, metadata = {}, optimize = true, compression: compressionOption = 4 }: APNGEncodeOptions & AnimationOptions): AnimationEncoder { + static __apng__(width: number, height: number, { loopCount: count = Infinity, metadata = {}, optimize = true, compression: compressionOption = 4 }: APNGEncodeOptions & AnimationOptions): AnimationEncoder { const compression = compressionLevel(compressionOption); - canvas(width, height, loopCount); + const loops = loopCount(count); + canvas(width, height); const text = pngText(resolveMetadata(metadata)); // the frames are kept compressed (quickly): the pixel format, and whether opaque animations are stored without // transparency, depend on all of them; then they are converted and compressed one at a time, like encodeAPNG @@ -191,15 +194,14 @@ export class AnimationEncoder { for await (const f of frames()) scanner.add(f.pixels); const format = scanner.format(), encoded: png.EncodedAPNGFrame[] = []; for await (const { pixels, ...f } of frames()) encoded.push({ ...f, data: await png.frameData(pixels, f.width, f.height, format, compression) }); - return png.animationFile(encoded, width, height, toPlays(loopCount), format, text); + return png.animationFile(encoded, width, height, toPlays(loops), format, text); } }, optimize); } } -function canvas(width: number, height: number, loopCount: number): void { +function canvas(width: number, height: number): void { if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) throw new RangeError(`Invalid canvas size: ${width}x${height}`); - if (loopCount !== Infinity && (!Number.isInteger(loopCount) || loopCount < -1)) throw new RangeError('Invalid loop count'); } // a frame in the WebP codec's terms (odd offsets: one transparent pixel more on the left/top, blending makes it diff --git a/src/animation.ts b/src/animation.ts index 0ca85ea2..7a741e27 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -12,13 +12,13 @@ import { Frame, FrameDisposalModes } from './frame.ts'; import { ImageType } from './image-type.ts'; import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts'; import { Metadata, resolveMetadata, pngText, exifData, gifComments } from './metadata.ts'; -import { checkOptions, finite, compressionLevel } from './util/options.ts'; +import { checkOptions, finite, compressionLevel, loopCount } from './util/options.ts'; import { MAX_PIXELS, decoding } from './util/limits.ts'; import { AnimationEncoder } from './animation-encoder.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 || loopCount === Infinity ? 0 : loopCount + 1; -const fromPlays = (plays: number): number => plays === 0 ? -1 : plays - 1; +// loop counts: how many times the animation starts over (Infinity = forever); APNG and WebP count plays (0 = forever) +const toPlays = (count: number): number => count === Infinity ? 0 : count + 1; +const fromPlays = (plays: number): number => plays === 0 ? Infinity : plays - 1; // frames decoded fully composited, as full canvas frames function canvasFrames(width: number, height: number, frames: Array<{ pixels: Uint8Array | Uint8ClampedArray, duration: number }>): Frame[] { @@ -33,8 +33,9 @@ function canvasFrames(width: number, height: number, frames: Array<{ pixels: Uin /** Options of {@link Animation} */ export interface AnimationOptions { /** - * How many times to repeat the animation (`Infinity` = forever, 0 = play once, 1 = play twice, ...). `-1` still - * means forever, it will be removed in 4.0. Default: forever + * How many times to repeat the animation: `Infinity` loops forever, 0 plays once, 1 plays twice, ... Anything else + * throws: a RangeError unless it is an integer from 0 or `Infinity`, a TypeError unless it is a number. Default: + * `Infinity` */ loopCount?: number; } @@ -116,12 +117,8 @@ export interface APNGEncodeOptions extends AnimationEncodeOptions { * iterated (`for (const frame of animation)`), and its `length` is the number of frames. */ export class Animation { - /** - * How many times to repeat the animation (`Infinity` = forever, 0 = play once, 1 = play twice, ...). `-1` still - * means forever, it will be removed in 4.0, when `Infinity` is what animations that loop forever have (animations - * that loop forever have `-1` until then, also the decoded ones) - */ - loopCount: number; + /** @internal @ignore */ + private __loopCount__ = Infinity; /** @internal @ignore */ private __frames__: Frame[] = []; @@ -134,13 +131,27 @@ export class Animation { constructor(frames: Frame[], options?: AnimationOptions); constructor(frames: Frame[], options: AnimationOptions = {}) { checkOptions(options, 'Animation(frames, { loopCount: 2 })'); - const { loopCount = -1 } = options; + const { loopCount: count = Infinity } = options; + const loops = loopCount(count); this.frames = frames; + this.__loopCount__ = loops; + } - if (loopCount !== Infinity && (finite(loopCount, 'loop count') < -1 || !Number.isInteger(Number(loopCount)))) - throw new RangeError(`Invalid loop count: ${loopCount}`); + /** + * How many times to repeat the animation: `Infinity` (the default) loops forever, 0 plays once, 1 plays twice, ... + * Other values throw, see {@link AnimationOptions.loopCount}, and the loop count stays as it was + */ + get loopCount(): number { + return this.__loopCount__; + } - this.loopCount = loopCount; + /** + * Replaces the loop count (see {@link Animation.loopCount}). Anything else than `Infinity` or an integer from 0 + * throws, and the count stays as it was + * @param value The new loop count + */ + set loopCount(value: number) { + this.__loopCount__ = loopCount(value); } /** @@ -450,24 +461,24 @@ export class Animation { // (still PNGs decode like images) if (type === 'png' && !png.isAnimated(data)) { const image = await Image.decode(data, { maxPixels, applyOrientation, applyColorProfile }); - return new Animation(canvasFrames(image.width, image.height, [{ pixels: image.bitmap, duration: 0 }]), { loopCount: -1 }); + return new Animation(canvasFrames(image.width, image.height, [{ pixels: image.bitmap, duration: 0 }]), { loopCount: Infinity }); } if (type === 'png') { const decoded = await png.decodeAnimation(data, onlyExtractFirstFrame, maxPixels); const frames = orient(profile(canvasFrames(decoded.width, decoded.height, png.compositeAnimation(decoded)), decoded.icc), decoded.orientation); - return new Animation(frames, { loopCount: decoded.frames.length > 1 ? fromPlays(decoded.plays) : -1 }); + return new Animation(frames, { loopCount: decoded.frames.length > 1 ? fromPlays(decoded.plays) : Infinity }); } if (type === 'jxl') { const frames: Frame[] = []; - let loopCount = -1, icc: Uint8Array | undefined, space: jxl.ColorSpace | undefined; + let loops = Infinity, icc: Uint8Array | undefined, space: jxl.ColorSpace | undefined; let orientation = 1; for await (const decoded of jxl.frames(data, maxPixels, toneMapping, threads)) { const frame = new Frame(decoded.width, decoded.height, { duration: decoded.duration, disposalMode: FrameDisposalModes.BACKGROUND }); frame.bitmap = decoded.pixels; frames.push(frame); orientation = decoded.orientation; - loopCount = fromPlays(decoded.loopCount); + loops = fromPlays(decoded.loopCount); icc = decoded.icc; space = decoded.colorSpace; if (onlyExtractFirstFrame) break; @@ -488,7 +499,7 @@ export class Animation { frame.gainMap = null; } } - return new Animation(frames, { loopCount: frames.length > 1 ? loopCount : -1 }); + return new Animation(frames, { loopCount: frames.length > 1 ? loops : Infinity }); } if (type) return new Animation([Frame.from(await Image.decode(data, { maxPixels, applyOrientation, applyColorProfile, toneMapping, gainMap, threads }), { duration: 0 })]); diff --git a/src/codecs/gif.ts b/src/codecs/gif.ts index 6b5e8424..63586c05 100644 --- a/src/codecs/gif.ts +++ b/src/codecs/gif.ts @@ -25,7 +25,7 @@ export interface GIFFrame { export interface GIFData { width: number; height: number; - /** -1 loops forever, 0 plays once, n repeats n times */ + /** Infinity loops forever, 0 plays once, n repeats n times */ loopCount: number; frames: GIFFrame[]; } @@ -62,14 +62,14 @@ interface GraphicControl { /** * Encodes frames into a GIF - * @param {{width: number, height: number, loopCount?: number, frames: GIFFrame[]}} gif loopCount -1 loops forever, 0 plays once, n repeats n times + * @param {{width: number, height: number, loopCount?: number, frames: GIFFrame[]}} gif loopCount Infinity loops forever, 0 plays once, n repeats n times * @param {number} [quality=95] 1-100, scales the palette size (1 = 8 colors, 50 = 55, 90 and above = 256) * @return {Uint8Array} */ /** * @param comments written as comment extensions */ -export function encode({ width, height, loopCount = -1, frames }: Omit & { loopCount?: number }, quality = 95, comments: Uint8Array[] = []): Uint8Array { +export function encode({ width, height, loopCount = Infinity, frames }: Omit & { loopCount?: number }, quality = 95, comments: Uint8Array[] = []): Uint8Array { // frames that restore the background have a transparent index: decoders like Pillow and ffmpeg restore to it // (browsers to transparent), otherwise to the background color. So does the first frame then: Pillow decides by it // whether the animation has transparency at all @@ -99,7 +99,7 @@ export function header(width: number, height: number, loopCount: number, comment out.bytes(0x21, 0xff, 0x0b); out.string('NETSCAPE2.0'); out.bytes(0x03, 0x01); - out.u16(loopCount === -1 || loopCount === Infinity ? 0 : loopCount); + out.u16(loopCount === Infinity ? 0 : loopCount); out.bytes(0x00); } @@ -280,7 +280,7 @@ export function decode(data: Uint8Array, onlyFirstFrame = false, maxPixels = MAX const sub = reader.slice(length); if (sub[0] === 1 && length >= 3) { const count = sub[1] | sub[2] << 8; - loopCount = count === 0 ? -1 : count; + loopCount = count === 0 ? Infinity : count; } } } else skipSubBlocks(reader); diff --git a/src/util/options.ts b/src/util/options.ts index 70dba416..4bcc95f7 100644 --- a/src/util/options.ts +++ b/src/util/options.ts @@ -22,6 +22,17 @@ export function finite(value: number, name: string): number { return number; } +/** + * A loop count: `Infinity` (loop forever) or an integer from 0 (0 plays once, 1 plays twice, ...). Anything else that is + * a number throws a RangeError (`-1`, which 3.x meant as forever, says so), anything that isn't one a TypeError + */ +export function loopCount(value: unknown, name = 'loop count'): number { + if (typeof value !== 'number') throw new TypeError(`${name} must be a number (Infinity or an integer from 0), not ${value === null ? 'null' : typeof value}`); + if (value !== Infinity && (!Number.isInteger(value) || value < 0)) + throw new RangeError(`${name} must be Infinity or an integer from 0, not ${value}${value === -1 ? ' (use Infinity to loop forever)' : ''}`); + return value; +} + /** A quality argument (0-100) of the encoders that take one, finite and in range */ export function quality(value: number, name = 'quality'): number { const q = finite(value, name); diff --git a/tests/animation.js b/tests/animation.js index 27d5af4d..0a4909a9 100644 --- a/tests/animation.js +++ b/tests/animation.js @@ -69,8 +69,8 @@ const square = (color, w, h) => new Image(w, h).fill((x, y) => (x + y) % 5 === 0 if (!threw || animation.length !== 1) panic('frames should only take frames'); } - // loop counts: -1 forever, 0 once, n repeats - for (const loopCount of [-1, 0, 5]) { + // loop counts: Infinity forever, 0 once, n repeats + for (const loopCount of [Infinity, 0, 1, 5]) { // (different frames: identical ones are merged, and a single frame has no loop count) const two = new Animation([Frame.from(still), Frame.from(still.clone().invert())], {loopCount}); for (const format of ['APNG', 'WebP', 'GIF']) diff --git a/tests/gif.js b/tests/gif.js index 4dd8db13..4276a3ab 100644 --- a/tests/gif.js +++ b/tests/gif.js @@ -48,7 +48,7 @@ const panic = message => { panic(`roundtrip: frame ${i} differs`); if (decoded.frames[0].duration !== gif.frames[0].duration) panic('roundtrip: incorrect duration'); - if (decoded.loopCount !== -1) + if (decoded.loopCount !== Infinity) panic('roundtrip: incorrect loop count'); } diff --git a/tests/loop-count.js b/tests/loop-count.js new file mode 100644 index 00000000..30dce8df --- /dev/null +++ b/tests/loop-count.js @@ -0,0 +1,100 @@ +const {Image, Frame, Animation} = require('..'); + +const panic = message => { + console.error(message); + process.exit(1); +}; +const throwsA = (fn, type, label) => { + let error = null; + try { fn(); } catch (e) { error = e; } + if (!(error instanceof type)) panic(`${label}: should throw a ${type.name}, got ${error}`); +}; + +const frames = () => [Frame.from(new Image(4, 4).fill(0xff0000ff), {duration: 40}), Frame.from(new Image(4, 4).fill(0x00ff00ff), {duration: 60})]; +// each format: its encode method, its encoder factory, and the count the file stores when it loops forever or plays +// (the GIF's NETSCAPE block counts repetitions, APNG and WebP count plays) +const formats = {GIF: ['encodeGIF', 'gifEncoder'], APNG: ['encodeAPNG', 'apngEncoder'], WebP: ['encodeWebP', 'webpEncoder']}; + +// the NETSCAPE2.0 loop count of a GIF (0 = forever), or null if it has none (it plays once) +const netscape = bytes => { + const at = Buffer.from(bytes).indexOf('NETSCAPE2.0'); + return at < 0 ? null : Buffer.from(bytes).readUInt16LE(at + 13); +}; + +(async () => { + // the default is forever, everywhere + if (new Animation(frames()).loopCount !== Infinity || new Animation([]).loopCount !== Infinity) panic('the default loop count should be Infinity'); + for (const [format, [encode, factory]] of Object.entries(formats)) { + const animation = new Animation(frames()); + const bytes = await animation[encode](); + const encoder = Animation[factory](4, 4); + for (const frame of frames()) await encoder.add(frame); + if (!Buffer.from(await encoder.finish()).equals(Buffer.from(bytes))) panic(`${format}: the encoder's default isn't the animation's`); + if (encode === 'encodeGIF' && netscape(bytes) !== 0) panic(`${format}: forever should be stored as NETSCAPE count 0, not ${netscape(bytes)}`); + + // decoded: a file that loops forever is Infinity + const back = await Animation.decode(bytes); + if (back.loopCount !== Infinity) panic(`${format}: a file that loops forever decodes as ${back.loopCount}`); + } + + // Infinity is forever, explicitly, in every entry point + for (const [format, [encode, factory]] of Object.entries(formats)) { + const forever = await new Animation(frames(), {loopCount: Infinity})[encode](); + if (!Buffer.from(forever).equals(Buffer.from(await new Animation(frames())[encode]()))) panic(`${format}: Infinity isn't the default`); + const encoder = Animation[factory](4, 4, {loopCount: Infinity}); + for (const frame of frames()) await encoder.add(frame); + if (!Buffer.from(await encoder.finish()).equals(Buffer.from(forever))) panic(`${format}: the encoder with Infinity`); + const setter = new Animation(frames(), {loopCount: 2}); + setter.loopCount = Infinity; + if (setter.loopCount !== Infinity) panic(`${format}: the loopCount setter`); + } + + // finite counts: 0 plays once, n repeats n times, through the constructor, the setter and each encoder + for (const count of [0, 1, 5]) { + for (const [format, [encode, factory]] of Object.entries(formats)) { + const bytes = await new Animation(frames(), {loopCount: count})[encode](); + if (format === 'GIF' && netscape(bytes) !== (count === 0 ? null : count)) panic(`GIF: loop count ${count} stored as ${netscape(bytes)}`); + const back = await Animation.decode(bytes); + if (back.loopCount !== count) panic(`${format}: loop count ${count} decodes as ${back.loopCount}`); + + const encoder = Animation[factory](4, 4, {loopCount: count}); + for (const frame of frames()) await encoder.add(frame); + if (!Buffer.from(await encoder.finish()).equals(Buffer.from(bytes))) panic(`${format}: encoder with loop count ${count} differs`); + + const set = new Animation(frames()); + set.loopCount = count; + if (set.loopCount !== count || !Buffer.from(await set[encode]()).equals(Buffer.from(bytes))) panic(`${format}: setter with loop count ${count}`); + } + } + + // a single frame has no loop count of its own: it's the default, forever + if ((await Animation.decode(await new Animation([frames()[0]]).encodeAPNG())).loopCount !== Infinity) panic('a still APNG should decode as Infinity'); + + // what isn't a loop count is refused by every entry point, with a RangeError for numbers and a TypeError for the rest, + // and a refused value changes nothing + const numbers = [-1, -5, NaN, 1.5]; + const others = ['2', null, true]; + for (const value of [...numbers, ...others]) { + const expected = numbers.includes(value) ? RangeError : TypeError; + const label = `loop count ${typeof value === 'string' ? JSON.stringify(value) : value}`; + + throwsA(() => new Animation(frames(), {loopCount: value}), expected, `new Animation(frames, { loopCount: ${label} })`); + const list = frames(); + throwsA(() => new Animation(list, {loopCount: value}), expected, `new Animation with ${label}`); + if (list.length !== 2) panic(`${label}: the frames given were changed`); + + const animation = new Animation(frames(), {loopCount: 3}); + throwsA(() => { animation.loopCount = value; }, expected, `loopCount = ${label}`); + if (animation.loopCount !== 3) panic(`${label}: a refused loopCount changed the animation to ${animation.loopCount}`); + + throwsA(() => Animation.gifEncoder(4, 4, {loopCount: value}), expected, `gifEncoder with ${label}`); + throwsA(() => Animation.apngEncoder(4, 4, {loopCount: value}), expected, `apngEncoder with ${label}`); + throwsA(() => Animation.webpEncoder(4, 4, {loopCount: value}), expected, `webpEncoder with ${label}`); + } + + // -1 meant forever in 3.x: it says so in the message, and it is refused like any other negative number + throwsA(() => new Animation(frames(), {loopCount: -1}), RangeError, 'loopCount -1'); + try { new Animation(frames(), {loopCount: -1}); } catch (error) { + if (!/Infinity/.test(error.message)) panic(`-1: the message should name Infinity, got ${error.message}`); + } +})().catch(error => panic(error.stack || String(error))); diff --git a/tests/options-only.js b/tests/options-only.js index d0328fed..a3544971 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -115,7 +115,7 @@ const sameImage = (image, reference, label) => { const frame = new Frame(4, 4, {duration: 40, xOffset: 2, yOffset: 3, disposalMode: 'background'}); if (frame.duration !== 40 || frame.xOffset !== 2 || frame.yOffset !== 3 || frame.disposalMode !== 'background') panic('new Frame(width, height, options)'); if (Frame.from(photo, {duration: 40, xOffset: 2}).xOffset !== 2 || Frame.from(photo).duration !== 100) panic('Frame.from(image, options)'); - if (new Animation([new Frame(4, 4)], {loopCount: 2}).loopCount !== 2 || new Animation([new Frame(4, 4)]).loopCount !== -1) panic('new Animation(frames, options)'); + if (new Animation([new Frame(4, 4)], {loopCount: 2}).loopCount !== 2 || new Animation([new Frame(4, 4)]).loopCount !== Infinity) panic('new Animation(frames, options)'); const encodedGIF = await new Animation([new Frame(4, 4)]).encodeGIF(); if ((await Animation.decode(encodedGIF, {onlyFirstFrame: true})).length !== 1) panic('Animation.decode(data, { onlyFirstFrame })'); const oldFrames = { diff --git a/tests/regressions.js b/tests/regressions.js index 0d06ffbe..f7a06dc6 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -452,17 +452,17 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); } }); - // Infinity is a loop count for ever (like -1, which is removed in 4.0), the encoders take the options of the encode functions + // Infinity is the loop count for ever (the default), the encoders take the options of the encode functions await withFrozenTime(async () => { const {Animation} = require('..'); const still = new Image(6, 6).fill(0x336699ff); const frames = () => [Frame.from(still.clone(), {duration: 100}), Frame.from(still.clone().invert(), {duration: 100})]; - const forever = new Animation(frames(), {loopCount: Infinity}), minusOne = new Animation(frames(), {loopCount: -1}); + const forever = new Animation(frames(), {loopCount: Infinity}); assert(forever.loopCount === Infinity && new Animation(frames(), {loopCount: Infinity}).loopCount === Infinity, 'Animation keeps Infinity'); for (const format of ['GIF', 'APNG', 'WebP']) { - const a = await forever[`encode${format}`](), b = await minusOne[`encode${format}`](); - assert(Buffer.from(a).equals(Buffer.from(b)), `${format}: Infinity vs -1`); - assert((await Animation.decode(a)).loopCount === -1, `${format}: decodes as -1 until 4.0`); + const a = await forever[`encode${format}`](); + assert(Buffer.from(a).equals(Buffer.from(await new Animation(frames())[`encode${format}`]())), `${format}: Infinity is the default`); + assert((await Animation.decode(a)).loopCount === Infinity, `${format}: decodes as Infinity`); const encoder = Animation[`${format.toLowerCase()}Encoder`](6, 6, {loopCount: Infinity}); for (const frame of frames()) await encoder.add(frame); assert(Buffer.from(await encoder.finish()).equals(Buffer.from(a)), `${format}: encoder with Infinity`); diff --git a/tests/webp.js b/tests/webp.js index 56324a13..f62d4f9b 100644 --- a/tests/webp.js +++ b/tests/webp.js @@ -46,7 +46,7 @@ const animated = [ } const animation = await Animation.decode(data); if (!(await decode(data) instanceof Animation)) panic('decode() should return an Animation for animated WebPs'); - if (animation.length !== 4 || animation.loopCount !== -1) panic('animated WebP: wrong frame or loop count'); + if (animation.length !== 4 || animation.loopCount !== Infinity) panic('animated WebP: wrong frame or loop count'); if (animation.frames.map(f => f.duration).join() !== '80,120,80,200') panic('animated WebP: wrong durations'); animation.frames.forEach((frame, i) => { if (sha256(frame.bitmap) !== animated[i]) panic(`animated WebP: frame ${i} doesn't match libwebp`); From c9ce47080cd573f767db426d0e8f12741acb9352 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 18:21:14 +0000 Subject: [PATCH 021/188] add a migration guide for 4.0 --- CHANGELOG.md | 2 + README.md | 2 + guides/Migrating to 4.0.md | 315 +++++++++++++++++++++++++++++++++++++ media/docs.css | 4 + typedoc.json | 3 + 5 files changed, 326 insertions(+) create mode 100644 guides/Migrating to 4.0.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 503cedcd..2149cc7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Breaking changes +Upgrading from 3.x? Upgrade to 3.20.1 first, fix the deprecation hints, then switch: the [migration guide](https://imagescript.dev/documents/Migrating_to_4.0.html) lists every change and the three that don't show up as deprecations. + - **Methods that take options refuse anything else than an object**: a stray number, string, boolean or `null` as the options of `cropCircle()`, `Image.renderText()`, the encoders (`encodeWebP()`, `encodeAVIF()`, `encodeJXL()`, ...), `quantize()`, `transform()`, `dropShadow()`, `outline()`, the gradients, `Image.decode()`, the animation encoders, `new GainMap()` and `new TextLayout()` throws a `TypeError` that names the working form (e.g. `cropCircle({ size: 'cover' })`), for the 3.x calls that are gone like `cropCircle(true)` or `Image.renderText(font, 32, 'Hi')` and for typos, instead of ignoring it. - **The deprecated resize, SVG and frame disposal constants are removed**: `Image.RESIZE_NEAREST_NEIGHBOR`, `Image.RESIZE_BILINEAR`, `Image.RESIZE_BICUBIC`, `Image.RESIZE_AREA`, `Image.SVG_MODE_SCALE`, `Image.SVG_MODE_WIDTH`, `Image.SVG_MODE_HEIGHT` and `Frame.DISPOSAL_*` are gone: use `ResizeModes`, `SVGScaleModes` and `FrameDisposalModes` instead. `ResizeModes.AUTO` (and `Image.RESIZE_AUTO`) is removed too: pass only the width or the height, `image.resize({ width: 256 })`. The old values are no longer accepted either: `'RESIZE_BILINEAR'`, the numbers 1-3 for SVG scale modes and the numeric disposal ids 0-3. diff --git a/README.md b/README.md index 5c06a488..f189ca6a 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ npm install imagescript # Node.js, Bun and bundlers deno add jsr:@matmen/imagescript # Deno ``` +Upgrading from 3.x? Read [Migrating to 4.0](https://imagescript.dev/documents/Migrating_to_4.0.html) first. + Without a bundler, import it from jsDelivr in the browser: ```html diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md new file mode 100644 index 00000000..d6256c61 --- /dev/null +++ b/guides/Migrating to 4.0.md @@ -0,0 +1,315 @@ +# Migrating to 4.0 + +ImageScript 4.0 removes everything 3.x deprecated, and changes three behaviours. Most of the removals are mechanical: +the compiler points at them. The three behaviour changes are not deprecations, nothing warns about them, and code can +keep compiling while it does something else. Read the first section even if your build is clean. + +Node.js 22.13 or newer is required, as it was for 3.x (`engines` in `package.json`). + +The recommended path: + +1. Upgrade to 3.20.1, the last 3.x release. Everything still works. Editors show the deprecated APIs with a + strikethrough, and the deprecation text names the replacement. `tsc` doesn't fail on them, so use the editor's + problems list, or a lint rule for deprecated APIs (typescript-eslint has `no-deprecated`), to get the full list. +2. Fix every deprecation hint, using the tables below. +3. Switch to 4.0 and go through the three behaviour changes. The [checklist](#checklist) at the end has grep patterns + for finding the code they affect. + +## Behaviour changes + +These change what code does without a compile error. + +### `decode()` returns an `Animation` for files with several frames + +In 3.x, `decode()` returned a `GIF` for every GIF, including a GIF with one frame, an `Animation` for an animated WebP, +and an `Image` for everything else. An APNG or a JPEG XL with several frames decoded to its first frame, as an `Image`. + +In 4.0, a file with several frames (GIF, APNG, animated WebP or JPEG XL) decodes to an `Animation`. A file with one +frame decodes to an `Image`, like a still image, so a one-frame GIF is an `Image` now. + +```ts +// 3.x: a GIF was a GIF, which is an Animation with encode() +const gif = await decode(data); +const out = await gif.encode(90); + +// 4.0: check what you got +import { Animation, decode } from 'imagescript'; + +const decoded = await decode(data); +if (decoded instanceof Animation) { + const out = await decoded.encodeGIF({ quality: 90 }); +} +``` + +With `onlyFirstFrame` on `decode()`, the file isn't read past its first frame, so the frame count is not known. The +first frame comes back as an `Image`. + +Search your code for: `decode(` where the result is used for `encode`, `.length`, indexing or `instanceof GIF`, and +`onlyFirstFrame`. + +### `Animation` holds its frames instead of extending `Array` + +In 3.x, an `Animation` was an array of its frames, so `animation[0]` and `animation.push()` worked, and `animation.map()` +returned a plain array. In 4.0 the frames are in `animation.frames`, and the animation is not an array. + +```ts +// 3.x +const first = animation[0]; +animation.push(frame); +const copy = animation.slice(0, 10); +const widths = animation.map(f => f.width); + +// 4.0 +const first = animation.frames[0]; +animation.frames.push(frame); +const copy = new Animation(animation.frames.slice(0, 10), { loopCount: animation.loopCount }); +const widths = animation.frames.map(f => f.width); +``` + +These still work: `for (const frame of animation)`, `[...animation]`, `Array.from(animation)`, `animation.length` and +the methods of `Animation` itself (`resize()`, `encodeGIF()`, `width`, `duration`, ...). `Array.isArray(animation)` is +`false` now. + +Two details: `animation.length` is read-only, so `animation.length = 0` throws. Assigning `animation.frames = list` +copies `list` into a new array, so later changes to `list` don't reach the animation. Change `animation.frames` instead. + +Search your code for: `Array.isArray`, `.slice(`, `.map(`, `.filter(`, `.forEach(`, `.reduce(`, `.push(`, `.splice(`, `.length =` +and `[0]` on variables that hold an animation. + +### Loop counts + +In 3.x, `-1` meant "forever", and decoded animations reported `-1`. In 4.0, `Infinity` is forever, it is the default, +and decoded animations report `Infinity`. + +```ts +// 3.x +new Animation(frames, { loopCount: -1 }); // forever +animation.loopCount === -1; // true for a decoded animation that loops + +// 4.0 +new Animation(frames); // forever, the default +new Animation(frames, { loopCount: Infinity }); +animation.loopCount === Infinity; +``` + +The finite counts are the same: `0` plays once, `2` plays three times. The valid values are `Infinity` and integers from +0 ({@link AnimationOptions.loopCount}). `-1` throws a `RangeError`, like other negative numbers, `NaN` and fractions. Numeric strings, `null` and booleans +throw a `TypeError`. The check applies to the constructor, to the setter `animation.loopCount = ...` (which keeps the +old count when it throws), and to the encoders `Animation.gifEncoder()`, `webpEncoder()` and `apngEncoder()`. + +Search your code for: `loopCount: -1`, `loopCount = -1`, `loopCount === -1` and `loopCount < 0`. + +## Removed APIs + +Each table maps a 3.x API to its 4.0 replacement. The deprecation hint in 3.x gives the same mapping. The replacements +are documented on their own pages: {@link ResizeModes}, {@link SVGScaleModes}, {@link FrameDisposalModes}, +{@link Colors}, {@link Frame}, {@link Animation}, {@link AnimationEncoder} and {@link Metadata}. + +### Constants and modes + +| 3.x | 4.0 | +| --- | --- | +| `Image.RESIZE_NEAREST_NEIGHBOR` | `ResizeModes.NEAREST_NEIGHBOR` (`'nearest-neighbor'`) | +| `Image.RESIZE_BILINEAR` | `ResizeModes.BILINEAR` (`'bilinear'`) | +| `Image.RESIZE_BICUBIC` | `ResizeModes.BICUBIC` (`'bicubic'`) | +| `Image.RESIZE_AREA` | `ResizeModes.AREA` (`'area'`) | +| `Image.RESIZE_AUTO`, `ResizeModes.AUTO` | Pass only the width or the height: `image.resize({ width: 256 })` | +| The upper-case mode strings, `'RESIZE_BILINEAR'` and the others | The lower-case names, `'bilinear'` and the others | +| `Image.SVG_MODE_SCALE` (or `1`) | `SVGScaleModes.SCALE` (`'scale'`) | +| `Image.SVG_MODE_WIDTH` (or `2`) | `SVGScaleModes.WIDTH` (`'width'`) | +| `Image.SVG_MODE_HEIGHT` (or `3`) | `SVGScaleModes.HEIGHT` (`'height'`) | +| `Frame.DISPOSAL_ANY`, `Frame.DISPOSAL_KEEP`, `Frame.DISPOSAL_PREVIOUS`, `Frame.DISPOSAL_BACKGROUND` | `FrameDisposalModes.ANY`, `.KEEP`, `.PREVIOUS`, `.BACKGROUND` | +| Numeric disposal ids: `frame.disposalMode = 3`, `disposalMode: 3` in the frame options | The names: `frame.disposalMode = 'background'`, `disposalMode: 'background'`. `FrameDisposalModeId` is removed | + +The old string and number values are not accepted any more, so `image.resize(256, 256, 'RESIZE_BICUBIC')` and +`renderSVG(svg, { mode: 2 })` throw. + +### Colors, pixels and encoding on `Image` + +| 3.x | 4.0 | +| --- | --- | +| `image.encode(options)` | `image.encodePNG(options)`. The positional forms are in the positional table below | +| `Image.rgbaToColor(r, g, b, a)` | `Colors.fromRGBA(r, g, b, a)` | +| `Image.rgbToColor(r, g, b)` | `Colors.fromRGB(r, g, b)` | +| `Image.hslaToColor(h, s, l, a)` | `Colors.fromHSLA(h, s, l, a)` | +| `Image.hslToColor(h, s, l)` | `Colors.fromHSL(h, s, l)` | +| `Image.rgbaToHSLA(r, g, b, a)` | `Colors.toHSLA(color)` | +| `Image.colorToRGBA(color)` | `Colors.toRGBA(color)` | +| `Image.colorToRGB(color)` | `Colors.toRGB(color)` | +| `Image.gradient(colors)` | `Colors.gradient(colors)` | +| `for (const [x, y] of image)` | `for (const [x, y] of image.coordinates())` | +| `image.iterateWithColors()` | `image.pixels()`, which yields `[x, y, color]` | + +The color helpers take the same arguments and return the same values, so `Image.rgbaToColor(...)` becomes +`Colors.fromRGBA(...)` and nothing else changes. `Colors` is exported from the package. + +### Channel methods and `absolute` + +`opacity()`, `red()`, `green()`, `blue()`, `lightness()` and `saturation()` only scale now. The `absolute` argument is +removed, and so is the `AdjustOptions` type. + +| 3.x | 4.0 | +| --- | --- | +| `image.opacity(0.5)`, `image.red(0.5)` (scale) | Unchanged | +| `image.red(0.5, true)`, `image.opacity(0.5, true)`, `image.lightness(0.5, true)` | See below | +| `image.red(0.5, { absolute: true })` | See below | + +The extra argument is not an error at runtime, so `image.red(0.5, true)` in JavaScript now scales the channel and +does not fail: on a pixel with red 0, the result stays 0, where 3.x set it to 128. `tsc` reports the call +(`Expected 1 arguments, but got 2`), so the risk is in JavaScript and in untyped code. + +For a fixed color, use `image.recolor(color)`. It sets the RGB of every pixel and keeps each pixel's alpha. The 3.x +`absolute` calls set one channel of every pixel to a value, and `recolor()` has no equivalent for a single channel. Set +the bytes yourself when you need it, on an image without a gain map: + +```ts +// 3.x: every pixel's red channel to 128 +image.red(0.5, true); + +// 4.0 +for (let i = 0; i < image.bitmap.length; i += 4) image.bitmap[i] = 0.5 * 255; +``` + +`bitmap` is RGBA, four bytes per pixel. Writing it directly skips the gain map of an HDR image, so do not use it on +images that have one. `absolute` on `lightness()` and `saturation()` sets the value of every pixel and keeps its hue. Do it per pixel: +read the color with `Colors.toHSLA()`, set the lightness or saturation, and write the pixel back with +`Colors.fromHSLA()`. + +### Animations and GIF + +| 3.x | 4.0 | +| --- | --- | +| `new GIF(frames, loopCount)` | `new Animation(frames, { loopCount })` | +| `GIF.decode(data)` | `Animation.decode(data)`, which decodes APNGs and WebPs too | +| `gif.encode(quality)` | `animation.encodeGIF({ quality })` | +| `GIF` (the class, also in `instanceof`) | `Animation`. `GIF` is no longer exported | +| `animation.slice(start, end)` | `new Animation(animation.frames.slice(start, end), { loopCount: animation.loopCount })` | +| `animation[i]` and the array methods | `animation.frames[i]` and `animation.frames.` | +| `AnimationEncoder.frames` | `AnimationEncoder.frameCount` | +| `toString()` of a GIF, `GIF<64x64x1000ms>` | `Animation<64x64x1000ms>` | + +The option types of the animation encoders are renamed, and each one takes `loopCount` through `AnimationOptions`: + +| 3.x | 4.0 | +| --- | --- | +| `AnimationEncoderOptions` | `AnimationEncodeOptions & AnimationOptions` | +| `WebPAnimationEncoderOptions` | `WebPAnimationEncodeOptions & AnimationOptions` | +| `GIFAnimationEncoderOptions` | `GIFEncodeOptions & AnimationOptions` | +| `APNGAnimationEncoderOptions` | `APNGEncodeOptions & AnimationOptions` | + +### Text, crop and metadata + +| 3.x | 4.0 | +| --- | --- | +| `Image.renderText(font, size, text)` | `Image.renderText(text, { font, size })` | +| `Image.renderText(font, size, text, color, layout, style)` | `Image.renderText(text, { font, size, color, layout, ...style })` | +| `Image.renderText(font, size, text, options)` | `Image.renderText(text, { font, size, ...options })` | +| `TextOptions` (type) | `RenderTextOptions` | +| `image.cropCircle(true)` | `image.cropCircle({ size: 'cover' })` | +| `image.cropCircle(true, feathering)` | `image.cropCircle({ size: 'cover', feathering })` | +| `image.cropCircle(false)` | `image.cropCircle()`, the default `size: 'contain'` | +| `image.cropCircle({ max: true })` | `image.cropCircle({ size: 'cover' })` | +| `PNGMetadata` (type) | `Metadata`, which all encoders take | + +`renderText()` takes the text first. The font, the size, the color and the layout are all in the options object, and +`layout` takes a {@link TextLayout} instance or its options. The options are documented in {@link RenderTextOptions}. +Crop options are in {@link CropCircleOptions}. A key that is not an option is ignored without an error, so +`cropCircle({ max: true })` quietly becomes the default `contain`: change it to `size: 'cover'`. + +### Positional arguments + +Methods that took options as their last positional arguments take the options object now. The values go into the +object under the names of their options. The options of each method are on its own page, for example +{@link ResizeOptions}, {@link CompositeOptions}, {@link RotateOptions}, {@link SharpenOptions}, +{@link DominantColorOptions}, {@link RoundCornersOptions}, {@link FisheyeOptions}, {@link TrimOptions}, +{@link ColorToAlphaOptions}, {@link PNGEncodeOptions}, {@link JPEGEncodeOptions}, {@link SVGOptions} and +{@link FrameOptions}. The animation options are {@link GIFEncodeOptions}, {@link APNGEncodeOptions}, +{@link AnimationDecodeOptions} and {@link GainMapEncodeOptions} for `GainMap.encode()`. + +| 3.x | 4.0 | +| --- | --- | +| `image.resize(256, 256, 'bicubic')`, `image.resize(256, 256, ResizeModes.AREA)` | `image.resize(256, 256, { mode: 'bicubic' })` | +| `image.scale(0.5, 'area')`, `image.scale(0.5, ResizeModes.AREA)` | `image.scale(0.5, { mode: 'area' })` | +| `image.contain(w, h, mode)`, `image.fit(w, h, mode)`, `image.cover(w, h, mode)` | `image.contain(w, h, { mode })`, and the same for `fit` and `cover` | +| `Frame` and `Animation`: `resize(w, h, mode)` | `resize(w, h, { mode })` | +| `image.composite(logo, 10, 20, { opacity: 0.5 })` | `image.composite(logo, { x: 10, y: 20, opacity: 0.5 })` | +| `image.rotate(45, false)` | `image.rotate(45, { resize: false })` | +| `image.sharpen(2, 3)` | `image.sharpen({ amount: 2, radius: 3 })` | +| `image.dominantColor(true, true, 15)` | `image.dominantColor({ ignoreBlack: true, ignoreWhite: true, threshold: 15 })` | +| `image.roundCorners(8)` | `image.roundCorners({ radius: 8 })` | +| `image.fisheye(3)` | `image.fisheye({ strength: 3 })` | +| `image.trim(color, options)`, `frame.trim(color, options)` | `image.trim({ color, ...options })` | +| `image.colorToAlpha(color, options)` | `image.colorToAlpha({ color, ...options })` | +| `image.encodePNG(1, { author })` | `image.encodePNG({ compression: 1, metadata: { author } })` | +| `image.encodePNG({ author })` (metadata alone) | `image.encodePNG({ metadata: { author } })`. The old form throws, see below | +| `image.encodeJPEG(90)` | `image.encodeJPEG({ quality: 90 })` | +| `animation.encodeGIF(90)` | `animation.encodeGIF({ quality: 90 })` | +| `animation.encodeAPNG(9)` | `animation.encodeAPNG({ compression: 9 })` | +| `gainMap.encode(90)` | `gainMap.encode({ quality: 90 })` | +| `Image.renderSVG(svg, 2, 'width')` | `Image.renderSVG(svg, { size: 2, mode: 'width' })` | +| `new Frame(w, h, duration, x, y, disposal)` | `new Frame(w, h, { duration, xOffset, yOffset, disposalMode })` | +| `Frame.from(image, duration, x, y, disposal)` | `Frame.from(image, { duration, xOffset, yOffset, disposalMode })` | +| `new Animation(frames, loopCount)` | `new Animation(frames, { loopCount })` | +| `Animation.decode(data, true)` | `Animation.decode(data, { onlyFirstFrame: true })` | + +The top-level `decode(data, true)` still takes its boolean. It is `Animation.decode()` that takes options only. + +`encodePNG()` also throws when the metadata is passed without `metadata`. In 3.x, `image.encodePNG({ author })` was +read as metadata; in 4.0 it would be ignored, so it throws a `TypeError` that names `{ metadata }`. + +## Methods that take options refuse other arguments + +A method that takes options now throws a `TypeError` when its argument is anything other than an object (or left out). +That covers numbers, strings, booleans and `null`. The message names the form that works, for example: + +```text +TypeError: cropCircle() takes an options object, like cropCircle({ size: 'cover' }), not boolean +``` + +This is how the positional calls you missed show up at runtime. It applies to every method that took options in 3.x, +including `cropCircle()`, `Image.renderText()`, `quantize()`, `transform()`, `dropShadow()`, `outline()`, the +gradients, `Image.decode()`, the encoders, `Animation.decode()`, `new GainMap()` and `new TextLayout()`. + +## Checklist + +Grep patterns for the affected code. They find candidates; read each hit, because some names are used by other code +(for example `.encode(` on a non-image object). + +```sh +# constants and modes +grep -rnE 'RESIZE_|SVG_MODE_|DISPOSAL_' src + +# encode() on images, and the color helpers +grep -rnE '\.encode\(' src +grep -rnE 'rgbaToColor|rgbToColor|hslaToColor|hslToColor|rgbaToHSLA|colorToRGBA?|Image\.gradient' src + +# iteration over images +grep -rnE 'iterateWithColors|for \(const \[[a-z]+, ?[a-z]+\] of' src + +# types and classes +grep -rnE 'PNGMetadata|AnimationEncoderOptions|AdjustOptions|TextOptions|FrameDisposalModeId|\bGIF\b' src + +# decode and animations +grep -rnE 'decode\(|onlyFirstFrame' src +grep -rnE 'loopCount\s*(:|=|===)\s*-1|loopCount\s*<\s*0' src +grep -rnE 'Array\.isArray|\.slice\(|\.frames\b|\.length\s*=' src + +# frames and animations: constructors and positional arguments +grep -rnE 'new (Frame|Animation|GIF)\(|Frame\.from\(|Animation\.decode\(' src +grep -rnE '\.(encodeGIF|encodeAPNG|encodeJPEG|encodePNG)\(\s*[0-9]' src + +# options that were positional +grep -rnE "\.(resize|scale|contain|fit|cover)\([^)]*, *(['\"]|ResizeModes\.|SVGScaleModes\.)" src +grep -rnE '\.(composite|rotate|sharpen|dominantColor|roundCorners|fisheye|trim|colorToAlpha)\([^)]' src +grep -rnE 'renderSVG\(|renderText\(|cropCircle\(' src +grep -rnE '\.(red|green|blue|opacity|lightness|saturation)\([^)]*,' src + +# GainMap +grep -rnE 'gainMap\.encode\(|\.encode\(\s*[0-9]' src +``` + +Also search for the variables that hold animations, and for `[0]`, `.map(`, `.filter(` and `.forEach(` on them: the +search can't tell an animation from an array, so check the type of each receiver. + +Then compile with `tsc`, and run the tests that cover your decode and encode paths. The three behaviour changes need +runtime checks: a file with several frames, a one-frame GIF, and any `loopCount: -1` in your code. diff --git a/media/docs.css b/media/docs.css index f6f12e46..a45dc095 100644 --- a/media/docs.css +++ b/media/docs.css @@ -480,6 +480,10 @@ a.external[target="_blank"]:has(> img) { } .tsd-typography table { + /* a wide table scrolls inside its panel instead of running past the page edge on narrow screens */ + display: block; + max-width: 100%; + overflow-x: auto; border-collapse: collapse; font-size: 0.9rem; } diff --git a/typedoc.json b/typedoc.json index 32738fd6..eadef143 100644 --- a/typedoc.json +++ b/typedoc.json @@ -6,6 +6,9 @@ "out": "docs", "name": "ImageScript", "readme": "README.md", + "projectDocuments": [ + "guides/*.md" + ], "favicon": "media/favicon.svg", "customCss": "media/docs.css", "includeVersion": true, From ec2d056e5b8cebefd7bc29875220d26c52582799 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 19:36:57 +0000 Subject: [PATCH 022/188] text alignments only take the values of their own axis --- CHANGELOG.md | 2 ++ guides/Migrating to 4.0.md | 1 + src/text-layout.ts | 28 +++++++++++----------------- tests/font.js | 2 +- tests/regressions.js | 9 ++++++--- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2149cc7a..8b59b020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,8 @@ Upgrading from 3.x? Upgrade to 3.20.1 first, fix the deprecation hints, then swi - **Loop counts are `Infinity` (forever, the default) or an integer from 0**: `loopCount: -1` is `loopCount: Infinity`, and the encoders and the decoded animations use `Infinity` where they used `-1`. `-1` now throws a `RangeError` like the other negative numbers, `NaN` and fractions do, and numeric strings, `null` and booleans throw a `TypeError`. The check applies to `new Animation(frames, { loopCount })`, `animation.loopCount = ...` (which leaves the count as it was when it throws) and the encoders (`Animation.gifEncoder()`, `webpEncoder()` and `apngEncoder()`). Finite counts are the same as before: `loopCount: 2` still plays three times. +- **Text alignments only take the values of their own axis**: `TextLayout`'s `horizontalAlign` takes `'left'`, `'center'` or `'right'` and `verticalAlign` takes `'top'`, `'middle'` or `'bottom'`. 3.x accepted the other axis' values in either field, which now throws a `TypeError`: `new TextLayout({ verticalAlign: 'right' })` is `new TextLayout({ horizontalAlign: 'right' })`. + ## 3.20.1 ### Performance diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index d6256c61..934243a8 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -205,6 +205,7 @@ The option types of the animation encoders are renamed, and each one takes `loop | `Image.renderText(font, size, text, color, layout, style)` | `Image.renderText(text, { font, size, color, layout, ...style })` | | `Image.renderText(font, size, text, options)` | `Image.renderText(text, { font, size, ...options })` | | `TextOptions` (type) | `RenderTextOptions` | +| `new TextLayout({ verticalAlign: 'right' })`, `horizontalAlign: 'bottom'` (the other axis' values) | `horizontalAlign` takes `'left'`, `'center'` or `'right'`, `verticalAlign` takes `'top'`, `'middle'` or `'bottom'`. Other values throw a `TypeError` | | `image.cropCircle(true)` | `image.cropCircle({ size: 'cover' })` | | `image.cropCircle(true, feathering)` | `image.cropCircle({ size: 'cover', feathering })` | | `image.cropCircle(false)` | `image.cropCircle()`, the default `size: 'contain'` | diff --git a/src/text-layout.ts b/src/text-layout.ts index 75ae91b3..46c9d6ff 100644 --- a/src/text-layout.ts +++ b/src/text-layout.ts @@ -15,18 +15,10 @@ export interface TextLayoutOptions { maxHeight?: number; /** Whether to wrap between words or at any character. Default: `'word'` */ wrapStyle?: WrapStyle; - /** - * Alignment of the lines relative to the widest line. Default: `'left'` - * (before 3.0 this was called verticalAlign, the values of the other axis (`'top'`, `'middle'`, `'bottom'`) are - * accepted for compatibility, which will be removed in 4.0) - */ - horizontalAlign?: HorizontalAlign | VerticalAlign; - /** - * Which lines to keep when cutting off at `maxHeight`. Default: `'top'` - * (before 3.0 this was called horizontalAlign, the values of the other axis (`'left'`, `'center'`, `'right'`) are - * accepted for compatibility, which will be removed in 4.0) - */ - verticalAlign?: VerticalAlign | HorizontalAlign; + /** Alignment of the lines relative to the widest line: `'left'`, `'center'` or `'right'`. Default: `'left'` */ + horizontalAlign?: HorizontalAlign; + /** Which lines to keep when cutting off at `maxHeight`: `'top'`, `'middle'` or `'bottom'`. Default: `'top'` */ + verticalAlign?: VerticalAlign; /** Whether to break lines at newline characters. Default: `true` */ wrapHardBreaks?: boolean; } @@ -72,11 +64,13 @@ export class TextLayout { if (!['word', 'char'].includes(this.wrapStyle)) throw new RangeError('Invalid wrapStyle'); - for (const value of [horizontalAlign, verticalAlign]) { - if (value === undefined) continue; - if (HORIZONTAL.includes(value)) this.horizontalAlign = value as HorizontalAlign; - else if (VERTICAL.includes(value)) this.verticalAlign = value as VerticalAlign; - else throw new RangeError(`Invalid alignment: ${value}`); + if (horizontalAlign !== undefined) { + if (!HORIZONTAL.includes(horizontalAlign)) throw new TypeError(`text alignments only take the values of their own axis: horizontalAlign takes 'left', 'center' or 'right', not '${horizontalAlign}'`); + this.horizontalAlign = horizontalAlign; + } + if (verticalAlign !== undefined) { + if (!VERTICAL.includes(verticalAlign)) throw new TypeError(`text alignments only take the values of their own axis: verticalAlign takes 'top', 'middle' or 'bottom', not '${verticalAlign}'`); + this.verticalAlign = verticalAlign; } this.wrapHardBreaks = typeof wrapHardBreaks === 'undefined' ? true : wrapHardBreaks; diff --git a/tests/font.js b/tests/font.js index 4e39050c..67d86d3a 100644 --- a/tests/font.js +++ b/tests/font.js @@ -100,7 +100,7 @@ const panic = message => { // alignment is relative to the widest line const left = await render('Hi\nHello there'); const center = await render('Hi\nHello there', {horizontalAlign: 'center'}); - const right = await render('Hi\nHello there', {verticalAlign: 'right'}); // pre 3.0 option name + const right = await render('Hi\nHello there', {horizontalAlign: 'right'}); const row = 30; if (!(inkStart(left, row) < inkStart(center, row) && inkStart(center, row) < inkStart(right, row))) panic('layout: horizontal alignment has no effect'); diff --git a/tests/regressions.js b/tests/regressions.js index f7a06dc6..2f730cb2 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -142,12 +142,15 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); assert(alpha(0, 0) < 64 && alpha(3, 0) === 255 && alpha(0, 1) > 0 && alpha(0, 1) < 255, 'roundCorners should antialias the arc'); } - // text layout accepts the pre 3.0 (swapped) option names + // text alignments only take the values of their own axis { - const legacy = new TextLayout({verticalAlign: 'center', horizontalAlign: 'bottom'}); - assert(legacy.horizontalAlign === 'center' && legacy.verticalAlign === 'bottom', 'TextLayout: legacy names'); const current = new TextLayout({horizontalAlign: 'right', verticalAlign: 'middle'}); assert(current.horizontalAlign === 'right' && current.verticalAlign === 'middle', 'TextLayout: names'); + for (const options of [{verticalAlign: 'center', horizontalAlign: 'bottom'}, {horizontalAlign: 'top'}, {verticalAlign: 'left'}, {horizontalAlign: 'middle'}]) { + let error = null; + try { new TextLayout(options); } catch (e) { error = e; } + assert(error instanceof TypeError && error.message.includes('text alignments only take the values of their own axis'), `TextLayout ${JSON.stringify(options)} should throw a TypeError, got ${error}`); + } } // HSL conversions round instead of truncating, so identity operations keep the image From c6fe4e660c69cb7ccd626f8c7ca42437a20f5fe6 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 19:38:37 +0000 Subject: [PATCH 023/188] decode() takes options only --- CHANGELOG.md | 2 ++ guides/Migrating to 4.0.md | 3 ++- src/index.ts | 18 ++++++------------ tests/options-only.js | 6 ++++-- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b59b020..4b1d9bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ Upgrading from 3.x? Upgrade to 3.20.1 first, fix the deprecation hints, then swi - **Text alignments only take the values of their own axis**: `TextLayout`'s `horizontalAlign` takes `'left'`, `'center'` or `'right'` and `verticalAlign` takes `'top'`, `'middle'` or `'bottom'`. 3.x accepted the other axis' values in either field, which now throws a `TypeError`: `new TextLayout({ verticalAlign: 'right' })` is `new TextLayout({ horizontalAlign: 'right' })`. +- **`decode()` takes options only**: `decode(data, true)` is `decode(data, { onlyFirstFrame: true })`, and a boolean throws a `TypeError` that names the options form. + ## 3.20.1 ### Performance diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index 934243a8..5fee7b80 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -252,8 +252,9 @@ object under the names of their options. The options of each method are on its o | `Frame.from(image, duration, x, y, disposal)` | `Frame.from(image, { duration, xOffset, yOffset, disposalMode })` | | `new Animation(frames, loopCount)` | `new Animation(frames, { loopCount })` | | `Animation.decode(data, true)` | `Animation.decode(data, { onlyFirstFrame: true })` | +| `decode(data, true)` | `decode(data, { onlyFirstFrame: true })` | -The top-level `decode(data, true)` still takes its boolean. It is `Animation.decode()` that takes options only. +`decode()` and `Animation.decode()` take options only: a boolean throws a `TypeError` that names the options form. `encodePNG()` also throws when the metadata is passed without `metadata`. In 3.x, `image.encodePNG({ author })` was read as metadata; in 4.0 it would be ignored, so it throws a `TypeError` that names `{ metadata }`. diff --git a/src/index.ts b/src/index.ts index 13349adf..001d4e94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { BinaryData } from './util/mem.ts'; import { Image, DecodeOptions } from './image.ts'; import { Frame } from './frame.ts'; import { Animation, AnimationDecodeOptions } from './animation.ts'; -import { isOptions } from './util/options.ts'; +import { checkOptions } from './util/options.ts'; import * as png from './codecs/png.ts'; import * as webp from './codecs/webp.ts'; import { view } from './util/mem.ts'; @@ -31,21 +31,15 @@ export type { Metadata } from './metadata.ts'; * returned as an {@link Animation} of its frames, and any other file as an {@link Image}: a file with a single frame * (also a GIF, APNG, WebP or JPEG XL with one frame) becomes an Image of that frame, without its offsets and duration. * Static formats (PNG, JPEG, AVIF, ...) are always Images, AVIF image sequences decode to their first frame, as - * {@link Image.decode} does. The options are passed on to {@link Animation.decode} or {@link Image.decode}. + * {@link Image.decode} does. The options are passed on to {@link Animation.decode} or {@link Image.decode}. With + * `onlyFirstFrame` the rest of the file isn't decoded, so the frame count isn't known: the first frame is returned as + * an Image. * @param data The image data * @param options See {@link AnimationDecodeOptions} and {@link DecodeOptions} */ export function decode(data: BinaryData, options?: AnimationDecodeOptions & DecodeOptions): Promise; -/** - * Decodes any supported image. A file with more than one frame is returned as an {@link Animation}, anything else as - * an {@link Image} (see the overload with options). With `onlyFirstFrame` the rest of the file isn't decoded, so the - * frame count isn't known: the first frame is returned as an Image. - * @param data The image data - * @param onlyFirstFrame Whether to end animation decoding after the first frame - */ -export function decode(data: BinaryData, onlyFirstFrame?: boolean): Promise; -export async function decode(data: BinaryData, onlyFirstFrameOrOptions: boolean | (AnimationDecodeOptions & DecodeOptions) = false): Promise { - const options: AnimationDecodeOptions & DecodeOptions = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions : { onlyFirstFrame: onlyFirstFrameOrOptions }; +export async function decode(data: BinaryData, options: AnimationDecodeOptions & DecodeOptions = {}): Promise { + checkOptions(options, 'decode(data, { onlyFirstFrame: true })'); const bytes = view(data); const type = ImageType.getType(bytes); const animated = type === 'gif' || type === 'jxl' || (type === 'webp' && webp.isAnimated(bytes)) || (type === 'png' && png.isAnimated(bytes)); diff --git a/tests/options-only.js b/tests/options-only.js index a3544971..02432911 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -1,4 +1,4 @@ -const {Image, Frame, Animation, GainMap, ResizeModes} = require('..'); +const {Image, Frame, Animation, GainMap, ResizeModes, decode} = require('..'); const panic = message => { console.error(message); @@ -125,8 +125,10 @@ const sameImage = (image, reference, label) => { 'Frame.from(photo, 100, 1, 1, "keep")': () => Frame.from(photo, 100, 1, 1, 'keep'), 'new Animation([frame], 2)': () => new Animation([frame], 2), 'new Animation([frame], Infinity)': () => new Animation([frame], Infinity), - 'Animation.decode(gif, true)': () => Animation.decode(encodedGIF, true) + 'Animation.decode(gif, true)': () => Animation.decode(encodedGIF, true), + 'decode(gif, true)': () => decode(encodedGIF, true) }; + if (!((await decode(encodedGIF, {onlyFirstFrame: true})) instanceof Image)) panic('decode(data, { onlyFirstFrame }) should return an Image'); for (const [label, call] of Object.entries(oldFrames)) { const error = await rejects(call, label); if (!/takes an options object/.test(error.message)) panic(`${label}: the message should name the options form, got ${error.message}`); From bfa22199ffee208fe856b1ca70b34eb07aa518b4 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 19:40:31 +0000 Subject: [PATCH 024/188] channel methods reject the removed absolute argument --- CHANGELOG.md | 2 ++ guides/Migrating to 4.0.md | 3 ++- src/image.ts | 8 +++++++- src/util/options.ts | 8 ++++++++ tests/misc.js | 10 +++++++--- tests/options-only.js | 12 ++++++++++++ 6 files changed, 38 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b1d9bbb..56977ae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,8 @@ Upgrading from 3.x? Upgrade to 3.20.1 first, fix the deprecation hints, then swi - **`decode()` takes options only**: `decode(data, true)` is `decode(data, { onlyFirstFrame: true })`, and a boolean throws a `TypeError` that names the options form. +- **`opacity()`, `red()`, `green()`, `blue()`, `lightness()` and `saturation()` reject a second argument**: 3.x's `absolute` call like `image.red(0.5, true)` would silently scale by 0.5 now, so it throws a `TypeError` before the image is touched. Use `image.red(0.5)` to scale and `image.recolor()` for a fixed color. + ## 3.20.1 ### Performance diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index 5fee7b80..3d8aa86d 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -146,7 +146,8 @@ The color helpers take the same arguments and return the same values, so `Image. ### Channel methods and `absolute` `opacity()`, `red()`, `green()`, `blue()`, `lightness()` and `saturation()` only scale now. The `absolute` argument is -removed, and so is the `AdjustOptions` type. +removed, and so is the `AdjustOptions` type. A second argument throws a `TypeError` before the image is touched, so +`image.red(0.5, true)` fails loudly instead of scaling. | 3.x | 4.0 | | --- | --- | diff --git a/src/image.ts b/src/image.ts index 61aad98d..55057dd7 100644 --- a/src/image.ts +++ b/src/image.ts @@ -33,7 +33,7 @@ import { resample, BOX, TRIANGLE, CUBIC, type Region } from './util/resample.ts' import { rotateBilinear } from './util/rotate.ts'; import { gaussianBlur, boxBlurRGBA, premultiply, unpremultiply } from './util/blur.ts'; import { Colors, wrapHue, hue2rgb } from './colors.ts'; -import { isOptions, checkOptions, finite, color as checkColor, quality as checkQuality, effort as checkEffort, compressionLevel } from './util/options.ts'; +import { isOptions, checkOptions, checkFactorOnly, finite, color as checkColor, quality as checkQuality, effort as checkEffort, compressionLevel } from './util/options.ts'; import { MAX_PIXELS, decoding } from './util/limits.ts'; import { GainMap } from './gainmap.ts'; import * as gain from './util/gain.ts'; @@ -1410,6 +1410,7 @@ export class Image { * @param opacity The factor to multiply the opacity with (0..1 lowers it, >1 raises it, results are clamped) */ opacity(opacity: number): this { + checkFactorOnly('opacity', arguments.length); opacity = finite(opacity, 'opacity value'); if (opacity < 0) throw new RangeError('Invalid opacity value'); @@ -1424,6 +1425,7 @@ export class Image { * @param factor The factor to multiply the channel with (>= 0, results are clamped) */ red(factor: number): this { + checkFactorOnly('red', arguments.length); if (this.__gain_map__) return this.__recolor__(image => image.red(factor)); factor = finite(factor, 'red factor'); if (factor < 0) @@ -1439,6 +1441,7 @@ export class Image { * @param factor The factor to multiply the channel with (>= 0, results are clamped) */ green(factor: number): this { + checkFactorOnly('green', arguments.length); if (this.__gain_map__) return this.__recolor__(image => image.green(factor)); factor = finite(factor, 'green factor'); if (factor < 0) @@ -1454,6 +1457,7 @@ export class Image { * @param factor The factor to multiply the channel with (>= 0, results are clamped) */ blue(factor: number): this { + checkFactorOnly('blue', arguments.length); if (this.__gain_map__) return this.__recolor__(image => image.blue(factor)); factor = finite(factor, 'blue factor'); if (factor < 0) @@ -1478,6 +1482,7 @@ export class Image { * @param value The factor to multiply the lightness with (>= 0, results are clamped) */ lightness(value: number): this { + checkFactorOnly('lightness', arguments.length); if (this.__gain_map__) return this.__recolor__(image => image.lightness(value)); value = finite(value, 'lightness value'); if (value < 0) @@ -1491,6 +1496,7 @@ export class Image { * @param value The factor to multiply the saturation with (>= 0, results are clamped) */ saturation(value: number): this { + checkFactorOnly('saturation', arguments.length); if (this.__gain_map__) return this.__recolor__(image => image.saturation(value), true); value = finite(value, 'saturation value'); if (value < 0) diff --git a/src/util/options.ts b/src/util/options.ts index 4bcc95f7..c097c720 100644 --- a/src/util/options.ts +++ b/src/util/options.ts @@ -12,6 +12,14 @@ export function checkOptions(value: unknown, usage: string): void { if (value !== undefined && !isOptions(value)) throw new TypeError(`${usage.split('(')[0]}() takes an options object, like ${usage}, not ${value === null ? 'null' : typeof value}`); } +/** + * The channel methods take a factor only: 3.x's `absolute` argument, a second one, would be a silent scale now, so more + * than one argument throws (before the image is touched). `method` is the name of the method + */ +export function checkFactorOnly(method: string, count: number): void { + if (count > 1) throw new TypeError(`channel methods reject the removed absolute argument: use ${method}(factor) to scale, or recolor() for a fixed color`); +} + /** * A number argument, converted like arithmetic does (numeric strings keep working); NaN and the infinities throw * a RangeError naming the argument diff --git a/tests/misc.js b/tests/misc.js index ad0e407e..28930332 100644 --- a/tests/misc.js +++ b/tests/misc.js @@ -157,10 +157,14 @@ if (Colors.fromHSLA(0, 0, 1, 0xff) !== 0xffffffff) } catch { } - const toPass = [[1, true], [1, false], [0, true], [0, false]]; + // the channel methods take a factor only: the 3.x absolute argument throws + const toReject = [[1, true], [1, false], [0, true], [0, false]]; for (const func of funcs) - for (const value of toPass) - func.call(image, ...value); + for (const value of toReject) { + let error = null; + try { func.call(image, ...value); } catch (e) { error = e; } + if (!(error instanceof TypeError)) panic(`${func.name} with a second argument should throw a TypeError, got ${error}`); + } } { diff --git a/tests/options-only.js b/tests/options-only.js index 02432911..dddabdb6 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -134,6 +134,18 @@ const sameImage = (image, reference, label) => { if (!/takes an options object/.test(error.message)) panic(`${label}: the message should name the options form, got ${error.message}`); } + // the channel methods take a factor only: a second argument (3.x's absolute) throws before the image is touched + for (const method of ['opacity', 'red', 'green', 'blue', 'lightness', 'saturation']) { + for (const second of [true, {absolute: true}]) { + const image = photo.clone(); + const before = Buffer.from(image.bitmap); + const error = await rejects(() => image[method](0.5, second), `${method}(0.5, ${JSON.stringify(second)})`); + if (!/channel methods reject the removed absolute argument/.test(error.message)) panic(`${method}: the message should name the replacement, got ${error.message}`); + if (!Buffer.from(image.bitmap).equals(before)) panic(`${method}(0.5, ${JSON.stringify(second)}) changed the image before throwing`); + } + } + if (new Image(1, 1).fill(0xc80000ff).red(0.5).getPixelAt(0, 0) !== 0x640000ff) panic('red(0.5) should scale the red channel'); + // the options form still resizes; undefined stays fine if (photo.clone().resize(10, 6, {mode: ResizeModes.AREA}).width !== 10) panic('resize(10, 6, { mode })'); if (photo.clone().resize(10, 6).height !== 6) panic('resize(10, 6)'); From d6dde6f16bff92d3fb2385b240b34593111d5aed Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 19:42:00 +0000 Subject: [PATCH 025/188] resize() needs a height or the options form --- CHANGELOG.md | 2 ++ guides/Migrating to 4.0.md | 1 + src/image.ts | 4 +++- tests/options-only.js | 14 ++++++++++++++ 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56977ae6..d08436f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ Upgrading from 3.x? Upgrade to 3.20.1 first, fix the deprecation hints, then swi - **`opacity()`, `red()`, `green()`, `blue()`, `lightness()` and `saturation()` reject a second argument**: 3.x's `absolute` call like `image.red(0.5, true)` would silently scale by 0.5 now, so it throws a `TypeError` before the image is touched. Use `image.red(0.5)` to scale and `image.recolor()` for a fixed color. +- **`resize()` needs a height in the positional form**: `image.resize(100)` throws a `TypeError` that names `resize({ width: 100 })`, which keeps the aspect ratio. The positional form takes both sides as finite numbers, `Image`, `Frame` and `Animation` alike. + ## 3.20.1 ### Performance diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index 3d8aa86d..43cdf925 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -232,6 +232,7 @@ object under the names of their options. The options of each method are on its o | --- | --- | | `image.resize(256, 256, 'bicubic')`, `image.resize(256, 256, ResizeModes.AREA)` | `image.resize(256, 256, { mode: 'bicubic' })` | | `image.scale(0.5, 'area')`, `image.scale(0.5, ResizeModes.AREA)` | `image.scale(0.5, { mode: 'area' })` | +| `image.resize(100)` (width only, the height left out) | `image.resize({ width: 100 })`, which keeps the aspect ratio. The positional form throws a `TypeError` without a height | | `image.contain(w, h, mode)`, `image.fit(w, h, mode)`, `image.cover(w, h, mode)` | `image.contain(w, h, { mode })`, and the same for `fit` and `cover` | | `Frame` and `Animation`: `resize(w, h, mode)` | `resize(w, h, { mode })` | | `image.composite(logo, 10, 20, { opacity: 0.5 })` | `image.composite(logo, { x: 10, y: 20, opacity: 0.5 })` | diff --git a/src/image.ts b/src/image.ts index 55057dd7..ec708d80 100644 --- a/src/image.ts +++ b/src/image.ts @@ -3038,7 +3038,9 @@ interface HSLA { export function resizeArguments(widthOrOptions: number | ResizeToOptions, height?: number, options?: ResizeOptions): [width: number | undefined, height: number | undefined, options: ResizeOptions | undefined] { if (!isOptions(widthOrOptions)) { checkOptions(options, 'resize(width, height, { mode: \'area\' })'); - return [widthOrOptions, height, options]; + // without a height, the aspect ratio would be kept silently (3.x refused that call) + if (height === undefined) throw new TypeError(`resize() needs a height or the options form, like resize({ width: ${widthOrOptions} }) to keep the aspect ratio`); + return [finite(widthOrOptions, 'width'), finite(height, 'height'), options]; } const { width, height: optionsHeight, ...rest } = widthOrOptions; if (width === undefined && optionsHeight === undefined) throw new TypeError('resize() needs a width, a height or both'); diff --git a/tests/options-only.js b/tests/options-only.js index dddabdb6..088ddcee 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -146,6 +146,20 @@ const sameImage = (image, reference, label) => { } if (new Image(1, 1).fill(0xc80000ff).red(0.5).getPixelAt(0, 0) !== 0x640000ff) panic('red(0.5) should scale the red channel'); + // the positional resize needs a height (3.x refused resize(100)): the message names the options form + for (const [label, call] of Object.entries({ + 'image.resize(100)': () => photo.clone().resize(100), + 'image.resize(100, undefined)': () => photo.clone().resize(100, undefined), + 'frame.resize(100)': () => new Frame(8, 4).resize(100), + 'animation.resize(100)': () => new Animation([new Frame(8, 4)]).resize(100) + })) { + const error = await rejects(call, label); + if (!/resize\(\) needs a height or the options form, like resize\(\{ width: 100 \}\)/.test(error.message)) panic(`${label}: the message should name resize({ width: 100 }), got ${error.message}`); + } + let nanError = null; + try { photo.clone().resize(NaN, 6); } catch (e) { nanError = e; } + if (!(nanError instanceof RangeError)) panic(`resize(NaN, 6) should throw a RangeError, got ${nanError}`); + // the options form still resizes; undefined stays fine if (photo.clone().resize(10, 6, {mode: ResizeModes.AREA}).width !== 10) panic('resize(10, 6, { mode })'); if (photo.clone().resize(10, 6).height !== 6) panic('resize(10, 6)'); From daf84123a558f5ae9c19a15e35a2b80aa305b361 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 19:43:44 +0000 Subject: [PATCH 026/188] share the loop count conversion --- src/animation-encoder.ts | 5 +---- src/animation.ts | 6 +----- src/util/options.ts | 9 +++++++++ 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/animation-encoder.ts b/src/animation-encoder.ts index 5db09c2e..dc522b73 100644 --- a/src/animation-encoder.ts +++ b/src/animation-encoder.ts @@ -6,12 +6,9 @@ import { Frame, FrameOptions } from './frame.ts'; import { Optimizer, Compositor, InputFrame, OptimizeOptions, OptimizedFrame, coverCanvas, fillUnchanged } from './util/optimize.ts'; import { compressAsync, decompressAsync } from './codecs/zlib.ts'; import { resolveMetadata, pngText, exifData, gifComments } from './metadata.ts'; -import { checkOptions, finite, compressionLevel, loopCount } from './util/options.ts'; +import { checkOptions, finite, compressionLevel, loopCount, toPlays } from './util/options.ts'; import type { AnimationOptions, WebPAnimationEncodeOptions, GIFEncodeOptions, APNGEncodeOptions } from './animation.ts'; -// (the loop count is how many times the animation starts over, Infinity forever; APNG and WebP count plays, 0 forever) -const toPlays = (count: number): number => count === Infinity ? 0 : count + 1; - // a format's part: takes the frames in order once they can't change anymore, then makes the file interface Format { readonly optimize: OptimizeOptions; diff --git a/src/animation.ts b/src/animation.ts index 7a741e27..17354d20 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -12,14 +12,10 @@ import { Frame, FrameDisposalModes } from './frame.ts'; import { ImageType } from './image-type.ts'; import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts'; import { Metadata, resolveMetadata, pngText, exifData, gifComments } from './metadata.ts'; -import { checkOptions, finite, compressionLevel, loopCount } from './util/options.ts'; +import { checkOptions, finite, compressionLevel, loopCount, toPlays, fromPlays } from './util/options.ts'; import { MAX_PIXELS, decoding } from './util/limits.ts'; import { AnimationEncoder } from './animation-encoder.ts'; -// loop counts: how many times the animation starts over (Infinity = forever); APNG and WebP count plays (0 = forever) -const toPlays = (count: number): number => count === Infinity ? 0 : count + 1; -const fromPlays = (plays: number): number => plays === 0 ? Infinity : plays - 1; - // frames decoded fully composited, as full canvas frames function canvasFrames(width: number, height: number, frames: Array<{ pixels: Uint8Array | Uint8ClampedArray, duration: number }>): Frame[] { return frames.map(({ pixels, duration }) => { diff --git a/src/util/options.ts b/src/util/options.ts index c097c720..ee67ef29 100644 --- a/src/util/options.ts +++ b/src/util/options.ts @@ -41,6 +41,15 @@ export function loopCount(value: unknown, name = 'loop count'): number { return value; } +/** + * The plays of an APNG or WebP header for a loop count: they count plays, so a loop count is one less (0 = forever, + * for Infinity) + */ +export const toPlays = (count: number): number => count === Infinity ? 0 : count + 1; + +/** The loop count of a plays field (0 = forever, which is Infinity), the inverse of {@link toPlays} */ +export const fromPlays = (plays: number): number => plays === 0 ? Infinity : plays - 1; + /** A quality argument (0-100) of the encoders that take one, finite and in range */ export function quality(value: number, name = 'quality'): number { const q = finite(value, name); From 5eff581dfec8732b733aec7945983aa7c931c672 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 19:45:09 +0000 Subject: [PATCH 027/188] refuse loop counts that don't fit the format --- CHANGELOG.md | 2 ++ guides/Migrating to 4.0.md | 5 +++++ src/animation-encoder.ts | 8 ++++---- src/animation.ts | 11 +++++++---- src/util/options.ts | 13 +++++++++++++ tests/loop-count.js | 20 ++++++++++++++++++++ 6 files changed, 51 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d08436f8..a4408747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,8 @@ Upgrading from 3.x? Upgrade to 3.20.1 first, fix the deprecation hints, then swi - **`resize()` needs a height in the positional form**: `image.resize(100)` throws a `TypeError` that names `resize({ width: 100 })`, which keeps the aspect ratio. The positional form takes both sides as finite numbers, `Image`, `Frame` and `Animation` alike. +- **Loop counts a format can't store throw a `RangeError`**: a GIF stores up to 65535 loops, a WebP up to 65534 and an APNG up to 2147483646 (2^31 - 2). A larger count used to wrap around silently (65536 as a GIF was forever). The encoders and the streaming encoders refuse it before encoding; `Infinity` is always fine. + ## 3.20.1 ### Performance diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index 43cdf925..412d042a 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -97,6 +97,11 @@ The finite counts are the same: `0` plays once, `2` plays three times. The valid throw a `TypeError`. The check applies to the constructor, to the setter `animation.loopCount = ...` (which keeps the old count when it throws), and to the encoders `Animation.gifEncoder()`, `webpEncoder()` and `apngEncoder()`. +The formats store a finite count in fixed-size fields: a GIF up to `65535`, a WebP up to `65534` and an APNG up to +`2147483646` (2^31 - 2). A larger count throws a `RangeError` before anything is encoded, from `encodeGIF()`, +`encodeWebP()`, `encodeAPNG()` and the streaming encoders. (Before, a GIF with `65536` loops wrapped to `0`, which is +forever.) `Infinity` is always fine. + Search your code for: `loopCount: -1`, `loopCount = -1`, `loopCount === -1` and `loopCount < 0`. ## Removed APIs diff --git a/src/animation-encoder.ts b/src/animation-encoder.ts index dc522b73..eaa75b37 100644 --- a/src/animation-encoder.ts +++ b/src/animation-encoder.ts @@ -6,7 +6,7 @@ import { Frame, FrameOptions } from './frame.ts'; import { Optimizer, Compositor, InputFrame, OptimizeOptions, OptimizedFrame, coverCanvas, fillUnchanged } from './util/optimize.ts'; import { compressAsync, decompressAsync } from './codecs/zlib.ts'; import { resolveMetadata, pngText, exifData, gifComments } from './metadata.ts'; -import { checkOptions, finite, compressionLevel, loopCount, toPlays } from './util/options.ts'; +import { checkOptions, finite, compressionLevel, loopCount, toPlays, fitLoops } from './util/options.ts'; import type { AnimationOptions, WebPAnimationEncodeOptions, GIFEncodeOptions, APNGEncodeOptions } from './animation.ts'; // a format's part: takes the frames in order once they can't change anymore, then makes the file @@ -92,7 +92,7 @@ export class AnimationEncoder { /** @internal */ static __webp__(width: number, height: number, { loopCount: count = Infinity, metadata = {}, optimize = true, quality, effort = 2, sharpYuv = false, alphaQuality = 100 }: WebPAnimationEncodeOptions & AnimationOptions): AnimationEncoder { - const loops = loopCount(count); + const loops = fitLoops(loopCount(count), 'WebP'); canvas(width, height); if (width > 1 << 24 || height > 1 << 24) throw new RangeError('WebP: canvas too large'); const lossy = webp.lossyOptions(quality, effort, sharpYuv, alphaQuality); @@ -123,7 +123,7 @@ export class AnimationEncoder { /** @internal */ static __gif__(width: number, height: number, { loopCount: count = Infinity, metadata = {}, optimize = true, quality: qualityOption = 95 }: GIFEncodeOptions & AnimationOptions): AnimationEncoder { const quality = finite(qualityOption, 'quality'); - const loops = loopCount(count); + const loops = fitLoops(loopCount(count), 'GIF'); canvas(width, height); const comments = gifComments(resolveMetadata(metadata)); // (the first frame is encoded last: it needs a transparent index if any frame restores the background) @@ -151,7 +151,7 @@ export class AnimationEncoder { /** @internal */ static __apng__(width: number, height: number, { loopCount: count = Infinity, metadata = {}, optimize = true, compression: compressionOption = 4 }: APNGEncodeOptions & AnimationOptions): AnimationEncoder { const compression = compressionLevel(compressionOption); - const loops = loopCount(count); + const loops = fitLoops(loopCount(count), 'APNG'); canvas(width, height); const text = pngText(resolveMetadata(metadata)); // the frames are kept compressed (quickly): the pixel format, and whether opaque animations are stored without diff --git a/src/animation.ts b/src/animation.ts index 17354d20..0acb77b3 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -12,7 +12,7 @@ import { Frame, FrameDisposalModes } from './frame.ts'; import { ImageType } from './image-type.ts'; import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts'; import { Metadata, resolveMetadata, pngText, exifData, gifComments } from './metadata.ts'; -import { checkOptions, finite, compressionLevel, loopCount, toPlays, fromPlays } from './util/options.ts'; +import { checkOptions, finite, compressionLevel, loopCount, toPlays, fromPlays, fitLoops } from './util/options.ts'; import { MAX_PIXELS, decoding } from './util/limits.ts'; import { AnimationEncoder } from './animation-encoder.ts'; @@ -263,6 +263,7 @@ export class Animation { async encodeGIF(options: GIFEncodeOptions = {}): Promise { checkOptions(options, 'encodeGIF({ quality: 80 })'); const { quality = 95, metadata = {}, optimize = true } = options; + const loops = fitLoops(this.loopCount, 'GIF'); this.__validate__(); const frames = optimize ? this.__optimized__({ replace: false, even: false, alpha: 'frame', palette: 256, cover: true }).map(f => ({ ...f, dispose: f.dispose ? 3 : 1 })) @@ -278,7 +279,7 @@ export class Animation { // gifs store centiseconds; rounding keeps e.g. 16 ms (60 fps) from becoming 10 ms const gifFrames = frames.map(({ duration, ...f }) => ({ ...f, delay: Math.round(duration / 10) })); - return gifCodec.encode({ width: this.width, height: this.height, loopCount: this.loopCount, frames: gifFrames }, finite(quality, 'quality'), gifComments(resolveMetadata(metadata))); + return gifCodec.encode({ width: this.width, height: this.height, loopCount: loops, frames: gifFrames }, finite(quality, 'quality'), gifComments(resolveMetadata(metadata))); } /** @@ -292,13 +293,14 @@ export class Animation { async encodeAPNG(options: APNGEncodeOptions = {}): Promise { checkOptions(options, 'encodeAPNG({ compression: 9 })'); const { compression = 4, metadata = {}, optimize = true } = options; + const loops = fitLoops(this.loopCount, 'APNG'); this.__validate__(); if (!this.length) throw new Error('An animation needs at least one frame'); const frames = optimize ? this.__optimized__({ replace: true, even: false, alpha: frames => png.alphaCost(frames.map(f => f.pixels)) }).map(f => ({ ...f, dispose: f.dispose ? 1 : 0 })) : this.__apngFrames__().map(({ blend: _, ...f }) => f); // blended, the encoder's default - return png.encodeAnimation(frames, this.width, this.height, toPlays(this.loopCount), compressionLevel(compression), pngText(resolveMetadata(metadata))); + return png.encodeAnimation(frames, this.width, this.height, toPlays(loops), compressionLevel(compression), pngText(resolveMetadata(metadata))); } /** @@ -310,13 +312,14 @@ export class Animation { async encodeWebP(options: WebPAnimationEncodeOptions = {}): Promise { checkOptions(options, 'animation.encodeWebP({ quality: 80 })'); const { metadata = {}, optimize = true, quality, effort = 2, sharpYuv = false, alphaQuality = 100 } = options; + const loops = fitLoops(this.loopCount, 'WebP'); this.__validate__(); if (!this.length) throw new Error('An animation needs at least one frame'); const lossy = webp.lossyOptions(quality, effort, sharpYuv, alphaQuality); const tolerance = lossy ? webp.lossyTolerance(lossy.quality) : undefined; const frames = optimize ? this.__optimized__({ replace: true, even: true, alpha: 'frame', tolerance }) : this.__webpFrames__(); - return webp.encodeAnimation(frames, this.width, this.height, toPlays(this.loopCount), exifData(resolveMetadata(metadata)), lossy, effort); + return webp.encodeAnimation(frames, this.width, this.height, toPlays(loops), exifData(resolveMetadata(metadata)), lossy, effort); } /** diff --git a/src/util/options.ts b/src/util/options.ts index ee67ef29..82d4706d 100644 --- a/src/util/options.ts +++ b/src/util/options.ts @@ -50,6 +50,19 @@ export const toPlays = (count: number): number => count === Infinity ? 0 : count /** The loop count of a plays field (0 = forever, which is Infinity), the inverse of {@link toPlays} */ export const fromPlays = (plays: number): number => plays === 0 ? Infinity : plays - 1; +/** The largest finite loop count each format stores: GIF 16 bits, WebP 16 bits of plays, APNG 31 bits of plays */ +const LOOP_LIMITS = { GIF: 65535, WebP: 65534, APNG: 2 ** 31 - 2 }; + +/** + * A loop count for a format's header: `Infinity` always fits, a finite count above the format's limit would wrap + * silently (65536 stored as 0 is forever), so it throws a RangeError + */ +export function fitLoops(count: number, format: keyof typeof LOOP_LIMITS): number { + const max = LOOP_LIMITS[format]; + if (count !== Infinity && count > max) throw new RangeError(`refuse loop counts that don't fit the format: ${format} loop counts go up to ${max}, not ${count}`); + return count; +} + /** A quality argument (0-100) of the encoders that take one, finite and in range */ export function quality(value: number, name = 'quality'): number { const q = finite(value, name); diff --git a/tests/loop-count.js b/tests/loop-count.js index 30dce8df..30726955 100644 --- a/tests/loop-count.js +++ b/tests/loop-count.js @@ -70,6 +70,26 @@ const netscape = bytes => { // a single frame has no loop count of its own: it's the default, forever if ((await Animation.decode(await new Animation([frames()[0]]).encodeAPNG())).loopCount !== Infinity) panic('a still APNG should decode as Infinity'); + // the largest count each format stores round-trips (GIF's 16 bits, WebP's 16 bits of plays, APNG's 31 bits of plays); + // one more would wrap around (65536 as a GIF is forever), so every entry point throws a RangeError instead + const limits = {GIF: 65535, WebP: 65534, APNG: 2 ** 31 - 2}; + for (const [format, max] of Object.entries(limits)) { + const [encode, factory] = formats[format]; + const bytes = await new Animation(frames(), {loopCount: max})[encode](); + if (format === 'GIF' && netscape(bytes) !== max) panic(`GIF: the largest count ${max} is stored as ${netscape(bytes)}`); + const back = await Animation.decode(bytes); + if (back.loopCount !== max) panic(`${format}: the largest count ${max} decodes as ${back.loopCount}`); + const encoder = Animation[factory](4, 4, {loopCount: max}); + for (const frame of frames()) await encoder.add(frame); + if (!Buffer.from(await encoder.finish()).equals(Buffer.from(bytes))) panic(`${format}: the encoder with the largest count ${max} differs`); + + const over = max + 1; + let error = null; + try { await new Animation(frames(), {loopCount: over})[encode](); } catch (e) { error = e; } + if (!(error instanceof RangeError) || !error.message.includes(`${format} loop counts go up to ${max}, not ${over}`)) panic(`${format}: ${over} should throw a RangeError naming ${max}, got ${error}`); + throwsA(() => Animation[factory](4, 4, {loopCount: over}), RangeError, `${format} encoder with ${over}`); + } + // what isn't a loop count is refused by every entry point, with a RangeError for numbers and a TypeError for the rest, // and a refused value changes nothing const numbers = [-1, -5, NaN, 1.5]; From 9cc04661095b5890e5ead12a0e167cb65c01de09 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 19:46:43 +0000 Subject: [PATCH 028/188] test that Animation.decode returns an Animation for single frames --- tests/decode-frames.js | 71 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/decode-frames.js b/tests/decode-frames.js index 3989301b..add4df60 100644 --- a/tests/decode-frames.js +++ b/tests/decode-frames.js @@ -73,4 +73,75 @@ const throwsA = async (fn, type, label) => { const jpeg = await fs.readFile('./tests/targets/external.jpg'); await throwsA(() => decode(jpeg, {maxPixels: 100}), RangeError, 'maxPixels on a JPEG'); if ((await decode(gif, {threads: 2})).length !== 30) panic('threads: frames changed'); + + // Animation.decode returns an Animation for every format, single frames included: only decode() unwraps them + const alwaysAnimation = async (name, bytes) => { + const animation = await Animation.decode(bytes); + if (!(animation instanceof Animation) || animation.length !== 1) panic(`${name}: Animation.decode gives ${animation.constructor.name} with ${animation.length} frames, not an Animation of 1`); + }; + const stillFiles = { + PNG: await photo.encodePNG(), + JPEG: await photo.encodeJPEG(), + 'WebP still': await photo.encodeWebP(), + AVIF: await photo.encodeAVIF({effort: 0}), + 'JPEG XL': await photo.encodeJXL(), + TGA: await photo.encodeTGA() + }; + for (const [name, bytes] of Object.entries(stillFiles)) { + await alwaysAnimation(name, bytes); + const image = await decode(bytes); + if (image instanceof Animation || !(image instanceof Image)) panic(`${name}: decode() should give an Image`); + } + const oneFrameFiles = {GIF: await single.encodeGIF(), APNG: await single.encodeAPNG(), 'WebP animation': await single.encodeWebP({quality: 90})}; + for (const [name, bytes] of Object.entries(oneFrameFiles)) await alwaysAnimation(name, bytes); + + // a one-frame APNG that keeps its acTL chunk (the encoder writes none for one frame): the two-frame file cut before + // its second frame, num_frames patched to 1, every chunk's CRC recomputed + const crcTable = Array.from({length: 256}, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; + }); + const crc32 = bytes => { + let c = 0xffffffff; + for (const b of bytes) c = crcTable[(c ^ b) & 255] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; + }; + const pngChunk = (type, data) => { + const out = new Uint8Array(12 + data.length), view = new DataView(out.buffer); + view.setUint32(0, data.length); + for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i); + out.set(data, 8); + view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length))); + return out; + }; + const apngBytes = new Uint8Array(apng); + const apngView = new DataView(apngBytes.buffer, apngBytes.byteOffset, apngBytes.byteLength); + const chunks = []; + for (let at = 8; at < apngBytes.length;) { + const length = apngView.getUint32(at); + chunks.push({type: String.fromCharCode(...apngBytes.subarray(at + 4, at + 8)), data: apngBytes.subarray(at + 8, at + 8 + length)}); + at += 12 + length; + } + let fcTLs = 0; + const second = chunks.findIndex(c => c.type === 'fcTL' && ++fcTLs === 2); + if (second < 0) panic('the two-frame APNG has no second fcTL'); + const parts = [apngBytes.subarray(0, 8)]; + for (const c of chunks.slice(0, second)) { + if (c.type !== 'acTL') { parts.push(pngChunk(c.type, c.data)); continue; } + const actl = new Uint8Array(c.data); + new DataView(actl.buffer).setUint32(0, 1); + parts.push(pngChunk('acTL', actl)); + } + parts.push(pngChunk('IEND', new Uint8Array(0))); + const oneApng = new Uint8Array(parts.reduce((n, part) => n + part.length, 0)); + for (let at = 0, i = 0; i < parts.length; i++) { oneApng.set(parts[i], at); at += parts[i].length; } + if (!chunks.some(c => c.type === 'acTL')) panic('the two-frame APNG has no acTL'); + + const oneAnimation = await Animation.decode(oneApng); + if (!(oneAnimation instanceof Animation) || oneAnimation.length !== 1) panic(`one-frame APNG with acTL: Animation.decode gives ${oneAnimation.constructor.name} with ${oneAnimation.length} frames`); + if (sha256(oneAnimation.frames[0].bitmap) !== sha256((await Animation.decode(apng)).frames[0].bitmap)) panic('one-frame APNG with acTL: the frame differs from the first frame of the two-frame file'); + const oneImage = await decode(oneApng); + if (oneImage instanceof Animation || !(oneImage instanceof Image)) panic('one-frame APNG with acTL: decode() should give an Image'); + if (sha256(oneImage.bitmap) !== sha256(oneAnimation.frames[0].bitmap)) panic('one-frame APNG with acTL: decode() pixels differ from Animation.decode'); })().catch(error => panic(error.stack || String(error))); From e3f4bda848f68529871753a8b94929b1a3b1ac25 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 19:48:09 +0000 Subject: [PATCH 029/188] changelog and guide: removed types, exact wording --- CHANGELOG.md | 4 +++- guides/Migrating to 4.0.md | 10 ++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4408747..4095efd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Upgrading from 3.x? Upgrade to 3.20.1 first, fix the deprecation hints, then switch: the [migration guide](https://imagescript.dev/documents/Migrating_to_4.0.html) lists every change and the three that don't show up as deprecations. -- **Methods that take options refuse anything else than an object**: a stray number, string, boolean or `null` as the options of `cropCircle()`, `Image.renderText()`, the encoders (`encodeWebP()`, `encodeAVIF()`, `encodeJXL()`, ...), `quantize()`, `transform()`, `dropShadow()`, `outline()`, the gradients, `Image.decode()`, the animation encoders, `new GainMap()` and `new TextLayout()` throws a `TypeError` that names the working form (e.g. `cropCircle({ size: 'cover' })`), for the 3.x calls that are gone like `cropCircle(true)` or `Image.renderText(font, 32, 'Hi')` and for typos, instead of ignoring it. +- **Methods that take options refuse anything else than an object**: a stray number, string, boolean or `null` as the options of `cropCircle()`, `Image.renderText()`, the encoders (`encodeWebP()`, `encodeAVIF()`, `encodeJXL()`, ...), `quantize()`, `transform()`, `dropShadow()`, `outline()`, the gradients, `Image.decode()`, the animation encoders, `new GainMap()` and `new TextLayout()` throws a `TypeError` that names the working form (e.g. `cropCircle({ size: 'cover' })`), for the 3.x calls that are gone like `cropCircle(true)` or `Image.renderText(font, 32, 'Hi')` instead of ignoring it. Only the type of the argument is checked: a key that isn't an option is still ignored, so a typo like `{ maxWith: 300 }` isn't caught. - **The deprecated resize, SVG and frame disposal constants are removed**: `Image.RESIZE_NEAREST_NEIGHBOR`, `Image.RESIZE_BILINEAR`, `Image.RESIZE_BICUBIC`, `Image.RESIZE_AREA`, `Image.SVG_MODE_SCALE`, `Image.SVG_MODE_WIDTH`, `Image.SVG_MODE_HEIGHT` and `Frame.DISPOSAL_*` are gone: use `ResizeModes`, `SVGScaleModes` and `FrameDisposalModes` instead. `ResizeModes.AUTO` (and `Image.RESIZE_AUTO`) is removed too: pass only the width or the height, `image.resize({ width: 256 })`. The old values are no longer accepted either: `'RESIZE_BILINEAR'`, the numbers 1-3 for SVG scale modes and the numeric disposal ids 0-3. - **`absolute` is removed from `opacity()`, `red()`, `green()`, `blue()`, `lightness()` and `saturation()`**: they only scale now, `image.red(0.5)` (and `AdjustOptions`, the type of their options, is removed). For a fixed color, use `image.recolor()`. @@ -53,6 +53,8 @@ Upgrading from 3.x? Upgrade to 3.20.1 first, fix the deprecation hints, then swi - **Loop counts a format can't store throw a `RangeError`**: a GIF stores up to 65535 loops, a WebP up to 65534 and an APNG up to 2147483646 (2^31 - 2). A larger count used to wrap around silently (65536 as a GIF was forever). The encoders and the streaming encoders refuse it before encoding; `Infinity` is always fine. +- **Removed and narrowed exported types**: `TextOptions` is `RenderTextOptions`, `FrameDisposalModeId` is removed (`FrameDisposalModeName` has the names), `AdjustOptions` is removed with `absolute`, `PNGMetadata` is `Metadata`, and `AnimationEncoderOptions`, `WebPAnimationEncoderOptions`, `GIFAnimationEncoderOptions` and `APNGAnimationEncoderOptions` are `AnimationEncodeOptions`, `WebPAnimationEncodeOptions`, `GIFEncodeOptions` and `APNGEncodeOptions`. `ResizeMode` and `SVGScaleMode` are narrowed to the lower-case names (`'area'`, `'scale'`, ...): the `RESIZE_*` strings and the numbers 1-3 are no longer part of the types. + ## 3.20.1 ### Performance diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index 412d042a..944d564a 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -8,7 +8,7 @@ Node.js 22.13 or newer is required, as it was for 3.x (`engines` in `package.jso The recommended path: -1. Upgrade to 3.20.1, the last 3.x release. Everything still works. Editors show the deprecated APIs with a +1. Upgrade to the latest 3.x release. Everything still works. Editors show the deprecated APIs with a strikethrough, and the deprecation text names the replacement. `tsc` doesn't fail on them, so use the editor's problems list, or a lint rule for deprecated APIs (typescript-eslint has `no-deprecated`), to get the full list. 2. Fix every deprecation hint, using the tables below. @@ -120,6 +120,7 @@ are documented on their own pages: {@link ResizeModes}, {@link SVGScaleModes}, { | `Image.RESIZE_AREA` | `ResizeModes.AREA` (`'area'`) | | `Image.RESIZE_AUTO`, `ResizeModes.AUTO` | Pass only the width or the height: `image.resize({ width: 256 })` | | The upper-case mode strings, `'RESIZE_BILINEAR'` and the others | The lower-case names, `'bilinear'` and the others | +| The `ResizeMode` and `SVGScaleMode` types, with the upper-case strings or the numbers 1-3 | Narrowed to the lower-case names: `ResizeMode` is `'nearest-neighbor'`, `'bilinear'`, `'bicubic'` or `'area'`, `SVGScaleMode` is `'scale'`, `'width'` or `'height'` | | `Image.SVG_MODE_SCALE` (or `1`) | `SVGScaleModes.SCALE` (`'scale'`) | | `Image.SVG_MODE_WIDTH` (or `2`) | `SVGScaleModes.WIDTH` (`'width'`) | | `Image.SVG_MODE_HEIGHT` (or `3`) | `SVGScaleModes.HEIGHT` (`'height'`) | @@ -158,11 +159,12 @@ removed, and so is the `AdjustOptions` type. A second argument throws a `TypeErr | --- | --- | | `image.opacity(0.5)`, `image.red(0.5)` (scale) | Unchanged | | `image.red(0.5, true)`, `image.opacity(0.5, true)`, `image.lightness(0.5, true)` | See below | +| `AdjustOptions` (type) | Removed with `absolute`: the channel methods take the factor only | | `image.red(0.5, { absolute: true })` | See below | -The extra argument is not an error at runtime, so `image.red(0.5, true)` in JavaScript now scales the channel and -does not fail: on a pixel with red 0, the result stays 0, where 3.x set it to 128. `tsc` reports the call -(`Expected 1 arguments, but got 2`), so the risk is in JavaScript and in untyped code. +The extra argument throws a `TypeError` in 4.0, so `image.red(0.5, true)` fails at the call, in JavaScript and in untyped +code, before the image is touched. (Had it been ignored, it would have scaled the channel: on a pixel with red 0 the +result stays 0, where 3.x's `absolute` set it to 128.) `tsc` reports the call as `Expected 1 arguments, but got 2`. For a fixed color, use `image.recolor(color)`. It sets the RGB of every pixel and keeps each pixel's alpha. The 3.x `absolute` calls set one channel of every pixel to a value, and `recolor()` has no equivalent for a single channel. Set From 04467805526f57f08df86ba7fd42649d46afed26 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 19:49:38 +0000 Subject: [PATCH 030/188] reject arguments that are not options in apngEncoder --- src/animation.ts | 1 + tests/options-objects-all.js | 1 + 2 files changed, 2 insertions(+) diff --git a/src/animation.ts b/src/animation.ts index 0acb77b3..c2b495b8 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -411,6 +411,7 @@ export class Animation { * @param options See {@link APNGEncodeOptions}, and {@link AnimationOptions.loopCount} */ static apngEncoder(width: number, height: number, options: APNGEncodeOptions & AnimationOptions = {}): AnimationEncoder { + checkOptions(options, 'Animation.apngEncoder(width, height, { compression: 9 })'); return AnimationEncoder.__apng__(width, height, options); } diff --git a/tests/options-objects-all.js b/tests/options-objects-all.js index 62dac043..97ab9a4a 100644 --- a/tests/options-objects-all.js +++ b/tests/options-objects-all.js @@ -26,6 +26,7 @@ const panic = message => { 'Animation.encodeWebP': bad => new Animation([frame]).encodeWebP(bad), 'Animation.webpEncoder': bad => Animation.webpEncoder(8, 8, bad), 'Animation.gifEncoder': bad => Animation.gifEncoder(8, 8, bad), + 'Animation.apngEncoder': bad => Animation.apngEncoder(8, 8, bad), 'AnimationEncoder.add': async bad => { const encoder = Animation.gifEncoder(16, 10); await encoder.add(photo, bad); }, 'new GainMap': bad => new GainMap(new Image(2, 2), bad), 'new TextLayout': bad => new TextLayout(bad) From 2807fb608edc660ab22229a1413fb1d79e2d6b6a Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 20:00:16 +0000 Subject: [PATCH 031/188] changelog: upgrade to the latest 3.x release first --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4095efd9..b7104c8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Breaking changes -Upgrading from 3.x? Upgrade to 3.20.1 first, fix the deprecation hints, then switch: the [migration guide](https://imagescript.dev/documents/Migrating_to_4.0.html) lists every change and the three that don't show up as deprecations. +Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation hints, then switch: the [migration guide](https://imagescript.dev/documents/Migrating_to_4.0.html) lists every change and the three that don't show up as deprecations. - **Methods that take options refuse anything else than an object**: a stray number, string, boolean or `null` as the options of `cropCircle()`, `Image.renderText()`, the encoders (`encodeWebP()`, `encodeAVIF()`, `encodeJXL()`, ...), `quantize()`, `transform()`, `dropShadow()`, `outline()`, the gradients, `Image.decode()`, the animation encoders, `new GainMap()` and `new TextLayout()` throws a `TypeError` that names the working form (e.g. `cropCircle({ size: 'cover' })`), for the 3.x calls that are gone like `cropCircle(true)` or `Image.renderText(font, 32, 'Hi')` instead of ignoring it. Only the type of the argument is checked: a key that isn't an option is still ignored, so a typo like `{ maxWith: 300 }` isn't caught. - **The deprecated resize, SVG and frame disposal constants are removed**: `Image.RESIZE_NEAREST_NEIGHBOR`, `Image.RESIZE_BILINEAR`, `Image.RESIZE_BICUBIC`, `Image.RESIZE_AREA`, `Image.SVG_MODE_SCALE`, `Image.SVG_MODE_WIDTH`, `Image.SVG_MODE_HEIGHT` and `Frame.DISPOSAL_*` are gone: use `ResizeModes`, `SVGScaleModes` and `FrameDisposalModes` instead. `ResizeModes.AUTO` (and `Image.RESIZE_AUTO`) is removed too: pass only the width or the height, `image.resize({ width: 256 })`. The old values are no longer accepted either: `'RESIZE_BILINEAR'`, the numbers 1-3 for SVG scale modes and the numeric disposal ids 0-3. From e30f5d00c00d7565e023253e30f585a6ab121df0 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 20:00:16 +0000 Subject: [PATCH 032/188] let prereleases use the changelog section of their release --- scripts/release-check.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/release-check.js b/scripts/release-check.js index 78924430..27d172fe 100644 --- a/scripts/release-check.js +++ b/scripts/release-check.js @@ -53,8 +53,10 @@ async function main() { : `the GitHub release is marked as prerelease, but ${version} has no prerelease suffix (like -beta.1)`); const changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); - if (!changelog.split('\n').some(line => /^##\s/.test(line) && line.replace(/^##\s+v?/, '').trim().split(/\s/)[0] === version)) - fail(`CHANGELOG.md has no "## ${version}" section`); + // (a prerelease may use the section of its release, so 4.0.0-alpha.1 and later prereleases share "## 4.0.0") + const release = version.replace(/-.*$/, ''); + if (!changelog.split('\n').some(line => /^##\s/.test(line) && [version, release].includes(line.replace(/^##\s+v?/, '').trim().split(/\s/)[0]))) + fail(`CHANGELOG.md has no "## ${version}" or "## ${release}" section`); // what JSR refuses when publishing, after npm has published (deno publish --dry-run doesn't check it): declaring // global types From 771cdcdc44b08d32d2d72c91f53e435d18cc5f73 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 20:11:02 +0000 Subject: [PATCH 033/188] Image.decode returns the first frame of animations --- CHANGELOG.md | 2 + guides/Migrating to 4.0.md | 9 ++++- src/image.ts | 21 +++++++--- tests/decode-frames.js | 83 ++++++++++++++++++++++++++++++++++++++ tests/option-checks.js | 5 ++- tests/webp.js | 11 ++--- 6 files changed, 114 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7104c8b..67adad91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **The positional arguments of `new Frame()`, `Frame.from()` and `new Animation()` are removed, and `Animation.decode()` takes no flag**: `new Frame(width, height, duration, xOffset, yOffset, disposalMode)` is `new Frame(width, height, { duration, xOffset, yOffset, disposalMode })`, `Frame.from(image, duration, xOffset, yOffset, disposalMode)` is `Frame.from(image, { duration, xOffset, yOffset, disposalMode })`, `new Animation(frames, loopCount)` is `new Animation(frames, { loopCount })`, and `Animation.decode(data, true)` is `Animation.decode(data, { onlyFirstFrame: true })`. +- **`Image.decode()` returns the first frame of an animation**: a GIF or an animated WebP, which threw in 3.x, is an `Image` of its first frame, composed onto the canvas as `Animation.decode()` composes it, and decoding stops after that frame, so it is fast on long animations. Animated JPEG XLs and AVIF sequences were already their first frame. An APNG is still its default image, what viewers without APNG support show. Use `Animation.decode()` for all the frames. + - **`decode()` returns an `Animation` only for files with several frames**: a GIF, APNG, animated WebP or JPEG XL with more than one frame is an `Animation` (`decode(gif)` used to be a `GIF`), and one with a single frame is an `Image`, like a still image (a one-frame GIF used to be a `GIF`). With `onlyFirstFrame`, the first frame comes back as an `Image`, since the frame count isn't read. - **The `GIF` class is removed**: `new GIF(frames, loopCount)` is `new Animation(frames, { loopCount })`, `GIF.decode(data)` is `Animation.decode(data)` and `gif.encode(quality)` is `animation.encodeGIF({ quality })`. `Animation` decodes and encodes GIFs the same way it does APNGs and WebPs. diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index 944d564a..2e0164ad 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -41,8 +41,13 @@ if (decoded instanceof Animation) { } ``` -With `onlyFirstFrame` on `decode()`, the file isn't read past its first frame, so the frame count is not known. The -first frame comes back as an `Image`. +`Image.decode()` returns the first frame of an animated GIF or WebP, which threw in 3.x. The frame is an `Image` composed +onto the canvas like the first frame of `Animation.decode()`, and decoding stops after it, so it is fast for a long +animation. Use `Animation.decode()` for all the frames. An APNG still gives its default image, as before. + +```ts +const first = await Image.decode(data); // the first frame of an animated GIF or WebP +``` Search your code for: `decode(` where the result is used for `encode`, `.length`, indexing or `instanceof GIF`, and `onlyFirstFrame`. diff --git a/src/image.ts b/src/image.ts index ec708d80..1d65f518 100644 --- a/src/image.ts +++ b/src/image.ts @@ -2726,9 +2726,10 @@ export class Image { /** * Decodes an image (PNG, JPEG, TIFF, WebP, BMP, ICO, CUR, QOI, PBM, PGM, PPM, PAM, PFM, Radiance HDR, TGA, AVIF or - * JPEG XL). For APNGs this is the default image; animated WebPs and GIFs have no single image, decode those with - * {@link Animation.decode}; for icons and cursors it's their largest image, for AVIF image sequences and JPEG XL - * animations their first frame + * JPEG XL). For animations (GIFs, animated WebPs, AVIF image sequences, JPEG XL animations) this is the first frame, + * composed onto the canvas as {@link Animation.decode} has it, and decoding stops after it: use + * {@link Animation.decode} for all frames. For APNGs this is the default image, which is what viewers without APNG + * support show; for icons and cursors it's their largest image * @param input The binary data to decode * @param options See {@link DecodeOptions}. JPEGs, TIFFs and PNGs are rotated/mirrored as their orientation tag * (Exif, for PNGs also XMP) says by default: photos taken with a rotated camera are stored sideways @@ -2748,8 +2749,10 @@ export class Image { if (ImageType.isJPEG(dataView)) return jpeg.decode(data, { maxPixels }); if (ImageType.isTIFF(dataView)) return tiff.decode(data, maxPixels); if (ImageType.isWebP(dataView)) { - if (webp.isAnimated(data)) throw new Error('Animated WebPs have to be decoded with Animation.decode()'); - return webp.decode(data, maxPixels); + if (!webp.isAnimated(data)) return webp.decode(data, maxPixels); + // the first frame only: the rest of the file isn't decoded + const animation = webp.decodeAnimation(data, true, maxPixels); + return { width: animation.width, height: animation.height, pixels: webp.composite(animation)[0].pixels, orientation: animation.orientation, icc: animation.icc }; } // bitmaps can hold a JPEG or PNG, icons PNGs const bitmap = async (decoded: bmp.Decoded): Promise<{ width: number, height: number, pixels: Uint8Array }> => { @@ -2766,7 +2769,13 @@ export class Image { if (ImageType.isPNM(dataView)) return netpbm.decode(data, maxPixels, toneMapping); if (ImageType.isHDR(dataView)) return hdr.decode(data, maxPixels, toneMapping); if (ImageType.isTGA(dataView)) return tga.decode(data, maxPixels); - if (ImageType.isGIF(dataView)) throw new Error('GIFs have to be decoded with Animation.decode() (or decode(), which returns an image or an animation)'); + if (ImageType.isGIF(dataView)) { + // the first frame only: the rest of the file isn't decoded + const gifData = gif.decode(data, true, maxPixels); + const [first] = gif.composite(gifData); + if (!first) throw new Error('GIF: no frames'); + return { width: gifData.width, height: gifData.height, pixels: first.pixels }; + } throw new Error('Unsupported image type'); }); diff --git a/tests/decode-frames.js b/tests/decode-frames.js index add4df60..d8be9a12 100644 --- a/tests/decode-frames.js +++ b/tests/decode-frames.js @@ -144,4 +144,87 @@ const throwsA = async (fn, type, label) => { const oneImage = await decode(oneApng); if (oneImage instanceof Animation || !(oneImage instanceof Image)) panic('one-frame APNG with acTL: decode() should give an Image'); if (sha256(oneImage.bitmap) !== sha256(oneAnimation.frames[0].bitmap)) panic('one-frame APNG with acTL: decode() pixels differ from Animation.decode'); + + // Image.decode of an animation is its first frame, an Image composed like Animation.decode's first frame: frames that + // differ, with offsets and disposal modes, from our encoders (GIF, APNG, animated WebP, lossless and lossy) + const moving = new Animation([ + new Frame(12, 8, {duration: 50}).fill((x, y) => Colors.fromRGBA(x * 20, y * 30, 90, 255)), + new Frame(5, 4, {duration: 70, xOffset: 3, yOffset: 2, disposalMode: 'background'}).fill((x, y) => Colors.fromRGBA(255, x * 50, y * 60, 128)), + new Frame(6, 3, {duration: 40, xOffset: 1, yOffset: 4, disposalMode: 'previous'}).fill(0x00ff00ff), + new Frame(4, 4, {duration: 60, xOffset: 8, yOffset: 4}).fill((x, y) => Colors.fromRGBA(y * 90, 0, 200, 255 - x * 60)) + ]); + const movingFiles = { + GIF: await moving.encodeGIF(), + 'GIF without optimization': await moving.encodeGIF({optimize: false}), + APNG: await moving.encodeAPNG(), + 'APNG without optimization': await moving.encodeAPNG({optimize: false}), + 'animated WebP': await moving.encodeWebP(), + 'lossy animated WebP': await moving.encodeWebP({quality: 80}) + }; + for (const [name, bytes] of Object.entries(movingFiles)) { + const image = await Image.decode(bytes); + if (image.constructor !== Image) panic(`${name}: Image.decode gives ${image.constructor.name}, not an Image`); + const frames = (await Animation.decode(bytes)).frames; + if (frames.length !== 4) panic(`${name}: ${frames.length} frames instead of 4`); + const reference = frames[0]; + if (image.width !== reference.width || image.height !== reference.height) panic(`${name}: ${image.width}x${image.height} instead of ${reference.width}x${reference.height}`); + if (sha256(image.bitmap) !== sha256(reference.bitmap)) panic(`${name}: Image.decode is not the first frame of Animation.decode`); + if (sha256(image.bitmap) === sha256(frames[1].bitmap)) panic(`${name}: the frames should differ`); + } + // options apply to the first frame: maxPixels counts the canvas + await throwsA(() => Image.decode(movingFiles.GIF, {maxPixels: 10}), RangeError, 'Image.decode maxPixels of an animated GIF'); + if ((await Image.decode(movingFiles.GIF, {maxPixels: 96})).width !== 12) panic('Image.decode: a GIF whose canvas fits maxPixels should decode'); + + // an APNG whose default image (IDAT) isn't part of the animation: Image.decode gives that image, what viewers without + // APNG support show, and Animation.decode only the frames of the animation + const pngList = bytes => { + const list = [], view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + for (let at = 8; at < bytes.length;) { + const length = view.getUint32(at); + list.push({type: String.fromCharCode(...bytes.subarray(at + 4, at + 8)), data: bytes.subarray(at + 8, at + 8 + length)}); + at += 12 + length; + } + return list; + }; + // (more colors than a palette holds, so both are truecolor PNGs without a PLTE chunk to copy) + const defaultPNG = new Uint8Array(await new Image(32, 32).fill((x, y) => Colors.fromRGBA(x * 7, y * 7, (x * y) & 255, 255)).encodePNG()); + const framePNG = new Uint8Array(await new Image(32, 32).fill((x, y) => Colors.fromRGBA(0, x * 7, y * 7, 255)).encodePNG()); + const ihdr = pngList(defaultPNG).find(c => c.type === 'IHDR').data; + const defaultIDAT = pngList(defaultPNG).find(c => c.type === 'IDAT').data; + const frameIDAT = pngList(framePNG).find(c => c.type === 'IDAT').data; + const actl = new Uint8Array(8); + new DataView(actl.buffer).setUint32(0, 1); // one frame in the animation, which the IDAT isn't part of + const fcTL = new Uint8Array(26), fcView = new DataView(fcTL.buffer); + fcView.setUint32(0, 0); // sequence number + fcView.setUint32(4, 32); // width + fcView.setUint32(8, 32); // height + fcView.setUint16(20, 100); // delay: 100/1000 s + fcView.setUint16(22, 1000); + const fdAT = new Uint8Array(4 + frameIDAT.length); + new DataView(fdAT.buffer).setUint32(0, 1); // sequence number + fdAT.set(frameIDAT, 4); + const separate = Buffer.concat([ + defaultPNG.subarray(0, 8), pngChunk('IHDR', ihdr), pngChunk('acTL', actl), pngChunk('IDAT', defaultIDAT), + pngChunk('fcTL', fcTL), pngChunk('fdAT', fdAT), pngChunk('IEND', new Uint8Array(0)) + ]); + const shown = await Image.decode(separate); + if (shown.constructor !== Image || sha256(shown.bitmap) !== sha256((await Image.decode(defaultPNG)).bitmap)) panic('APNG with a separate default image: Image.decode should give the default image'); + const animated = await Animation.decode(separate); + if (animated.length !== 1 || sha256(animated.frames[0].bitmap) !== sha256((await Image.decode(framePNG)).bitmap)) panic('APNG with a separate default image: Animation.decode should give only the animation\'s frame'); + + // a large animation: Image.decode stops after its first frame, so it takes a fraction of the time of all 50 frames + const many = new Animation(Array.from({length: 50}, (_, i) => new Frame(480, 360, {duration: 40}).fill((x, y) => Colors.fromRGBA((x * 7 + i * 13) & 255, (y * 5 + x * i) & 255, (x ^ y) & 255, 255)))); + const large = await many.encodeGIF(); + const median = async fn => { + const times = []; + for (let i = 0; i < 3; i++) { + const start = process.hrtime.bigint(); + await fn(); + times.push(Number(process.hrtime.bigint() - start) / 1e6); + } + return times.sort((a, b) => a - b)[1]; + }; + const firstMs = await median(() => Image.decode(large)); + const allMs = await median(() => Animation.decode(large)); + if (firstMs * 3 > allMs) panic(`Image.decode of 50 frames took ${firstMs.toFixed(1)} ms, Animation.decode ${allMs.toFixed(1)} ms: not a third`); })().catch(error => panic(error.stack || String(error))); diff --git a/tests/option-checks.js b/tests/option-checks.js index 8b11e4cb..5d26d078 100644 --- a/tests/option-checks.js +++ b/tests/option-checks.js @@ -61,9 +61,10 @@ const rejects = async (name, fn, Type, pattern) => { } await rejects('Animation.decode threads 0', () => Animation.decode(png, {threads: 0}), RangeError, /threads must be/); - // GIFs are refused by Image.decode() with a hint at Animation.decode() + // Image.decode takes the options object of a GIF too: maxPixels counts its first frame const gif = await new Animation([new Frame(4, 4).fill(0xff0000ff)]).encodeGIF(); - await rejects('Image.decode GIF', () => Image.decode(gif), Error, /Animation\.decode\(\)/); + await rejects('Image.decode GIF maxPixels', () => Image.decode(gif, {maxPixels: 15}), RangeError, /maxPixels/); + if ((await Image.decode(gif, {maxPixels: 16})).width !== 4) panic('Image.decode GIF: the first frame should fit maxPixels 16'); // valid values keep working for (const effort of [0, 1, 2]) await image.encodeAVIF({effort}); diff --git a/tests/webp.js b/tests/webp.js index f62d4f9b..586b7abc 100644 --- a/tests/webp.js +++ b/tests/webp.js @@ -36,14 +36,11 @@ const animated = [ if (sha256(image.bitmap) !== hash) panic(`${name}: decoded pixels don't match libwebp`); } - // animated WebPs: Image.decode refuses them, Animation.decode composites the frames + // animated WebPs: Image.decode gives the first frame as libwebp composites it, Animation.decode all of them const data = await fs.readFile('./tests/webp/animated.webp'); - try { - await Image.decode(data); - panic('Image.decode should reject animated WebPs'); - } catch (err) { - if (!/Animation\.decode/.test(err.message)) panic(`unexpected error ${err.message}`); - } + const firstFrame = await Image.decode(data); + if (firstFrame.constructor !== Image || firstFrame.width !== 96 || firstFrame.height !== 72) panic('animated WebP: Image.decode should give an Image of the canvas'); + if (sha256(firstFrame.bitmap) !== animated[0]) panic('animated WebP: Image.decode is not the first frame libwebp composites'); const animation = await Animation.decode(data); if (!(await decode(data) instanceof Animation)) panic('decode() should return an Animation for animated WebPs'); if (animation.length !== 4 || animation.loopCount !== Infinity) panic('animated WebP: wrong frame or loop count'); From 2dbd9ef400005811b779d6aee85b37fec86115e6 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 20:17:31 +0000 Subject: [PATCH 034/188] remove the onlyFirstFrame option --- CHANGELOG.md | 6 +++--- guides/Migrating to 4.0.md | 10 +++++----- src/animation.ts | 17 +++++++---------- src/codecs/gif.ts | 6 +++--- src/codecs/png.ts | 6 +++--- src/codecs/webp.ts | 6 +++--- src/index.ts | 6 ++---- tests/decode-frames.js | 8 ++++---- tests/gif.js | 8 ++++---- tests/jxl.js | 5 +++-- tests/options-only.js | 3 +-- tests/options.js | 3 +-- tests/regressions.js | 2 +- 13 files changed, 40 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67adad91..a10ca0da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,11 +33,11 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **The positional arguments of `Image.renderSVG()` are removed**: `Image.renderSVG(svg, size, mode)` is `Image.renderSVG(svg, { size, mode })`, which takes the same `fonts` and `images` as before: `Image.renderSVG(svg, 2, 'width')` is now `Image.renderSVG(svg, { size: 2, mode: 'width' })`. -- **The positional arguments of `new Frame()`, `Frame.from()` and `new Animation()` are removed, and `Animation.decode()` takes no flag**: `new Frame(width, height, duration, xOffset, yOffset, disposalMode)` is `new Frame(width, height, { duration, xOffset, yOffset, disposalMode })`, `Frame.from(image, duration, xOffset, yOffset, disposalMode)` is `Frame.from(image, { duration, xOffset, yOffset, disposalMode })`, `new Animation(frames, loopCount)` is `new Animation(frames, { loopCount })`, and `Animation.decode(data, true)` is `Animation.decode(data, { onlyFirstFrame: true })`. +- **The positional arguments of `new Frame()`, `Frame.from()` and `new Animation()` are removed, and `Animation.decode()` takes no flag**: `new Frame(width, height, duration, xOffset, yOffset, disposalMode)` is `new Frame(width, height, { duration, xOffset, yOffset, disposalMode })`, `Frame.from(image, duration, xOffset, yOffset, disposalMode)` is `Frame.from(image, { duration, xOffset, yOffset, disposalMode })`, `new Animation(frames, loopCount)` is `new Animation(frames, { loopCount })`, and `Animation.decode(data, true)` is `Animation.decode(data)`, or `Image.decode(data)` for the first frame only. - **`Image.decode()` returns the first frame of an animation**: a GIF or an animated WebP, which threw in 3.x, is an `Image` of its first frame, composed onto the canvas as `Animation.decode()` composes it, and decoding stops after that frame, so it is fast on long animations. Animated JPEG XLs and AVIF sequences were already their first frame. An APNG is still its default image, what viewers without APNG support show. Use `Animation.decode()` for all the frames. -- **`decode()` returns an `Animation` only for files with several frames**: a GIF, APNG, animated WebP or JPEG XL with more than one frame is an `Animation` (`decode(gif)` used to be a `GIF`), and one with a single frame is an `Image`, like a still image (a one-frame GIF used to be a `GIF`). With `onlyFirstFrame`, the first frame comes back as an `Image`, since the frame count isn't read. +- **`decode()` returns an `Animation` only for files with several frames**: a GIF, APNG, animated WebP or JPEG XL with more than one frame is an `Animation` (`decode(gif)` used to be a `GIF`), and one with a single frame is an `Image`, like a still image (a one-frame GIF used to be a `GIF`). For the first frame alone, `Image.decode()` returns an `Image`. - **The `GIF` class is removed**: `new GIF(frames, loopCount)` is `new Animation(frames, { loopCount })`, `GIF.decode(data)` is `Animation.decode(data)` and `gif.encode(quality)` is `animation.encodeGIF({ quality })`. `Animation` decodes and encodes GIFs the same way it does APNGs and WebPs. @@ -47,7 +47,7 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **Text alignments only take the values of their own axis**: `TextLayout`'s `horizontalAlign` takes `'left'`, `'center'` or `'right'` and `verticalAlign` takes `'top'`, `'middle'` or `'bottom'`. 3.x accepted the other axis' values in either field, which now throws a `TypeError`: `new TextLayout({ verticalAlign: 'right' })` is `new TextLayout({ horizontalAlign: 'right' })`. -- **`decode()` takes options only**: `decode(data, true)` is `decode(data, { onlyFirstFrame: true })`, and a boolean throws a `TypeError` that names the options form. +- **`decode()` takes options only**: `decode(data, true)` is `Image.decode(data)` for the first frame, and a boolean throws a `TypeError` that names the options form. - **`opacity()`, `red()`, `green()`, `blue()`, `lightness()` and `saturation()` reject a second argument**: 3.x's `absolute` call like `image.red(0.5, true)` would silently scale by 0.5 now, so it throws a `TypeError` before the image is touched. Use `image.red(0.5)` to scale and `image.recolor()` for a fixed color. diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index 2e0164ad..3e00497a 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -49,8 +49,7 @@ animation. Use `Animation.decode()` for all the frames. An APNG still gives its const first = await Image.decode(data); // the first frame of an animated GIF or WebP ``` -Search your code for: `decode(` where the result is used for `encode`, `.length`, indexing or `instanceof GIF`, and -`onlyFirstFrame`. +Search your code for: `decode(` where the result is used for `encode`, `.length`, indexing or `instanceof GIF`. ### `Animation` holds its frames instead of extending `Array` @@ -265,8 +264,9 @@ object under the names of their options. The options of each method are on its o | `new Frame(w, h, duration, x, y, disposal)` | `new Frame(w, h, { duration, xOffset, yOffset, disposalMode })` | | `Frame.from(image, duration, x, y, disposal)` | `Frame.from(image, { duration, xOffset, yOffset, disposalMode })` | | `new Animation(frames, loopCount)` | `new Animation(frames, { loopCount })` | -| `Animation.decode(data, true)` | `Animation.decode(data, { onlyFirstFrame: true })` | -| `decode(data, true)` | `decode(data, { onlyFirstFrame: true })` | +| `Animation.decode(data, true)` | `Image.decode(data)` for the first frame, `Animation.decode(data)` for all | +| `decode(data, true)` | `Image.decode(data)` | +| `decode(data, { onlyFirstFrame: true })` | `Image.decode(data)` | `decode()` and `Animation.decode()` take options only: a boolean throws a `TypeError` that names the options form. @@ -306,7 +306,7 @@ grep -rnE 'iterateWithColors|for \(const \[[a-z]+, ?[a-z]+\] of' src grep -rnE 'PNGMetadata|AnimationEncoderOptions|AdjustOptions|TextOptions|FrameDisposalModeId|\bGIF\b' src # decode and animations -grep -rnE 'decode\(|onlyFirstFrame' src +grep -rnE 'decode\(.*, *(true|false)\)' src grep -rnE 'loopCount\s*(:|=|===)\s*-1|loopCount\s*<\s*0' src grep -rnE 'Array\.isArray|\.slice\(|\.frames\b|\.length\s*=' src diff --git a/src/animation.ts b/src/animation.ts index c2b495b8..0e6315f8 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -38,8 +38,6 @@ export interface AnimationOptions { /** Options of {@link Animation.decode} */ export interface AnimationDecodeOptions { - /** Whether to stop decoding after the first frame. Default: `false` */ - onlyFirstFrame?: boolean; /** * The most pixels to decode, of all frames together (every frame is a full canvas): larger animations throw a * RangeError before anything that size is allocated, so small files declaring huge animations (decompression @@ -423,7 +421,7 @@ export class Animation { static decode(input: BinaryData, options?: AnimationDecodeOptions): Promise; static async decode(input: BinaryData, options: AnimationDecodeOptions = {}): Promise { // (checked outside of decoding(), which reports RangeErrors as damaged data) - checkOptions(options, 'Animation.decode(data, { onlyFirstFrame: true })'); + checkOptions(options, 'Animation.decode(data, { maxPixels: 1e8 })'); const { maxPixels = MAX_PIXELS, threads = 1 } = options; if (!Number.isInteger(threads) || threads < 1) throw new RangeError(`threads must be a positive integer, not ${threads}`); if (!(Number(maxPixels) > 0)) throw new RangeError(`maxPixels must be a positive number, not ${maxPixels}`); @@ -437,7 +435,7 @@ export class Animation { * @ignore */ private static async __decode__(input: BinaryData, options: AnimationDecodeOptions): Promise { - const { onlyFirstFrame: onlyExtractFirstFrame = false, maxPixels = MAX_PIXELS, applyColorProfile = true, threads = Math.max(1, Math.min(availableThreads(), 8)), applyOrientation = true, toneMapping = 'reinhard', gainMap = true } = options; + const { maxPixels = MAX_PIXELS, applyColorProfile = true, threads = Math.max(1, Math.min(availableThreads(), 8)), applyOrientation = true, toneMapping = 'reinhard', gainMap = true } = options; const orient = (frames: Frame[], orientation: number): Frame[] => { if (applyOrientation && orientation > 1) for (const frame of frames) frame.__orient__(orientation); return frames; @@ -450,10 +448,10 @@ export class Animation { const data = view(input); const type = ImageType.getType(data); - if (type === 'gif') return Animation.__gifFrames__(data, onlyExtractFirstFrame, maxPixels); + if (type === 'gif') return Animation.__gifFrames__(data, maxPixels); if (type === 'webp') { - const decoded = webp.decodeAnimation(data, onlyExtractFirstFrame, maxPixels); + const decoded = webp.decodeAnimation(data, false, maxPixels); const frames = orient(profile(canvasFrames(decoded.width, decoded.height, webp.composite(decoded)), decoded.icc), decoded.orientation); return new Animation(frames, { loopCount: fromPlays(decoded.loopCount) }); } @@ -464,7 +462,7 @@ export class Animation { return new Animation(canvasFrames(image.width, image.height, [{ pixels: image.bitmap, duration: 0 }]), { loopCount: Infinity }); } if (type === 'png') { - const decoded = await png.decodeAnimation(data, onlyExtractFirstFrame, maxPixels); + const decoded = await png.decodeAnimation(data, false, maxPixels); const frames = orient(profile(canvasFrames(decoded.width, decoded.height, png.compositeAnimation(decoded)), decoded.icc), decoded.orientation); return new Animation(frames, { loopCount: decoded.frames.length > 1 ? fromPlays(decoded.plays) : Infinity }); } @@ -481,7 +479,6 @@ export class Animation { loops = fromPlays(decoded.loopCount); icc = decoded.icc; space = decoded.colorSpace; - if (onlyExtractFirstFrame) break; } // (a gain map that can't be read leaves the frames without one, it doesn't fail the decoding) const map = gainMap && data[0] !== 0xff ? await jxl.decodeGainMap(data, maxPixels).catch(() => undefined) : undefined; @@ -510,8 +507,8 @@ export class Animation { * @internal * @ignore */ - private static __gifFrames__(data: Uint8Array, onlyExtractFirstFrame: boolean, maxPixels: number): Animation { - const decoded = gifCodec.decode(data, onlyExtractFirstFrame, maxPixels); + private static __gifFrames__(data: Uint8Array, maxPixels: number): Animation { + const decoded = gifCodec.decode(data, false, maxPixels); const frames = gifCodec.composite(decoded).map(({ pixels, delay }) => ({ pixels, duration: 10 * delay })); return new Animation(canvasFrames(decoded.width, decoded.height, frames), { loopCount: decoded.loopCount }); } diff --git a/src/codecs/gif.ts b/src/codecs/gif.ts index 63586c05..58281777 100644 --- a/src/codecs/gif.ts +++ b/src/codecs/gif.ts @@ -219,10 +219,10 @@ function writeSubBlocks(out: GIFWriter, data: Uint8Array): void { /** * Decodes a GIF into its individual (uncomposited) frames, zero-sized images are skipped * @param {Uint8Array} data - * @param {boolean} [onlyFirstFrame=false] + * @param {boolean} [firstFrameOnly=false] * @return {{width: number, height: number, loopCount: number, frames: GIFFrame[]}} */ -export function decode(data: Uint8Array, onlyFirstFrame = false, maxPixels = MAX_PIXELS): DecodedGIF { +export function decode(data: Uint8Array, firstFrameOnly = false, maxPixels = MAX_PIXELS): DecodedGIF { const reader = new ByteReader(data); const signature = reader.string(6); @@ -329,7 +329,7 @@ export function decode(data: Uint8Array, onlyFirstFrame = false, maxPixels = MAX } frames.push({ x, y, width: fw, height: fh, delay, dispose, indices: ordered, colors, transparentIndex }); - if (onlyFirstFrame) break; + if (firstFrameOnly) break; } } } diff --git a/src/codecs/png.ts b/src/codecs/png.ts index 932a3913..ebcc677b 100644 --- a/src/codecs/png.ts +++ b/src/codecs/png.ts @@ -718,12 +718,12 @@ export function isAnimated(array: Uint8Array): boolean { * a single frame * @return plays: 0 = forever */ -export async function decodeAnimation(array: Uint8Array, onlyFirstFrame = false, maxPixels = MAX_PIXELS): Promise<{ width: number, height: number, plays: number, frames: APNGFrame[], icc?: Uint8Array, orientation: number }> { +export async function decodeAnimation(array: Uint8Array, firstFrameOnly = false, maxPixels = MAX_PIXELS): Promise<{ width: number, height: number, plays: number, frames: APNGFrame[], icc?: Uint8Array, orientation: number }> { const p = parse(array); const { width, height } = p.header; const orientation = p.exif ? exifOrientation(p.exif) : await textOrientation(p.text); // every frame is composited onto a canvas of its own - checkPixels(width, height, maxPixels, !p.animation || p.animationError || onlyFirstFrame ? 1 : Math.max(1, p.frames.length)); + checkPixels(width, height, maxPixels, !p.animation || p.animationError || firstFrameOnly ? 1 : Math.max(1, p.frames.length)); // broken animations show the default image, like browsers do if (!p.animation || p.animationError) { @@ -741,7 +741,7 @@ export async function decodeAnimation(array: Uint8Array, onlyFirstFrame = false, blend: c.blend, pixels: await decodeImageData(p, data, c.width, c.height) }); - if (onlyFirstFrame) break; + if (firstFrameOnly) break; } return { width, height, plays: p.animation.plays, frames, icc: await profile(p.iccp), orientation }; diff --git a/src/codecs/webp.ts b/src/codecs/webp.ts index d7cdc9a1..e45b4bd5 100644 --- a/src/codecs/webp.ts +++ b/src/codecs/webp.ts @@ -183,7 +183,7 @@ export function decode(data: Uint8Array, maxPixels = MAX_PIXELS): { width: numbe /** * Decodes an animated WebP into its frames (not composited); still WebPs become a single frame */ -export function decodeAnimation(data: Uint8Array, onlyFirstFrame = false, maxPixels = MAX_PIXELS): WebPAnimation { +export function decodeAnimation(data: Uint8Array, firstFrameOnly = false, maxPixels = MAX_PIXELS): WebPAnimation { const list = container(data); const header = list[0]; if (!header || header.type !== VP8X || !list.some(c => c.type === ANMF)) { @@ -194,7 +194,7 @@ export function decodeAnimation(data: Uint8Array, onlyFirstFrame = false, maxPix if (header.data.length < 10) throw new Error('WebP: invalid VP8X chunk'); const width = u24(header.data, 4) + 1, height = u24(header.data, 7) + 1; // every frame is composited onto a canvas of its own - checkPixels(width, height, maxPixels, onlyFirstFrame ? 1 : list.filter(c => c.type === ANMF).length); + checkPixels(width, height, maxPixels, firstFrameOnly ? 1 : list.filter(c => c.type === ANMF).length); let loopCount = 0, background = 0; const frames: WebPFrame[] = []; @@ -218,7 +218,7 @@ export function decodeAnimation(data: Uint8Array, onlyFirstFrame = false, maxPix dispose: (d[15] & 1) === 1, pixels: frame.pixels }); - if (onlyFirstFrame) break; + if (firstFrameOnly) break; } } diff --git a/src/index.ts b/src/index.ts index 001d4e94..7edbcc05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,15 +31,13 @@ export type { Metadata } from './metadata.ts'; * returned as an {@link Animation} of its frames, and any other file as an {@link Image}: a file with a single frame * (also a GIF, APNG, WebP or JPEG XL with one frame) becomes an Image of that frame, without its offsets and duration. * Static formats (PNG, JPEG, AVIF, ...) are always Images, AVIF image sequences decode to their first frame, as - * {@link Image.decode} does. The options are passed on to {@link Animation.decode} or {@link Image.decode}. With - * `onlyFirstFrame` the rest of the file isn't decoded, so the frame count isn't known: the first frame is returned as - * an Image. + * {@link Image.decode} does. The options are passed on to {@link Animation.decode} or {@link Image.decode}. * @param data The image data * @param options See {@link AnimationDecodeOptions} and {@link DecodeOptions} */ export function decode(data: BinaryData, options?: AnimationDecodeOptions & DecodeOptions): Promise; export async function decode(data: BinaryData, options: AnimationDecodeOptions & DecodeOptions = {}): Promise { - checkOptions(options, 'decode(data, { onlyFirstFrame: true })'); + checkOptions(options, 'decode(data, { maxPixels: 1e8 })'); const bytes = view(data); const type = ImageType.getType(bytes); const animated = type === 'gif' || type === 'jxl' || (type === 'webp' && webp.isAnimated(bytes)) || (type === 'png' && png.isAnimated(bytes)); diff --git a/tests/decode-frames.js b/tests/decode-frames.js index d8be9a12..8dfafc73 100644 --- a/tests/decode-frames.js +++ b/tests/decode-frames.js @@ -65,10 +65,10 @@ const throwsA = async (fn, type, label) => { if (sha256(image.bitmap) !== sha256(reference.bitmap)) panic(`${name}: pixels differ from Image.decode`); } - // options pass through: onlyFirstFrame gives the first frame as an Image, maxPixels applies to both kinds - const first = await decode(gif, {onlyFirstFrame: true}); - if (first instanceof Animation || !(first instanceof Image)) panic('onlyFirstFrame: expected an Image'); - if (sha256(first.bitmap) !== sha256((await Animation.decode(gif, {onlyFirstFrame: true})).frames[0].bitmap)) panic('onlyFirstFrame: wrong frame'); + // options pass through: Image.decode gives the first frame as an Image, maxPixels applies to both kinds + const first = await Image.decode(gif); + if (first instanceof Animation || !(first instanceof Image)) panic('Image.decode: expected an Image'); + if (sha256(first.bitmap) !== sha256((await Animation.decode(gif)).frames[0].bitmap)) panic('Image.decode: wrong frame'); await throwsA(() => decode(gif, {maxPixels: 100}), RangeError, 'maxPixels on a GIF'); const jpeg = await fs.readFile('./tests/targets/external.jpg'); await throwsA(() => decode(jpeg, {maxPixels: 100}), RangeError, 'maxPixels on a JPEG'); diff --git a/tests/gif.js b/tests/gif.js index 4276a3ab..268d9928 100644 --- a/tests/gif.js +++ b/tests/gif.js @@ -1,6 +1,6 @@ const fs = require('fs').promises; const crypto = require('crypto'); -const {Frame, FrameDisposalModes, Animation, Colors} = require('..'); +const {Image, Frame, FrameDisposalModes, Animation, Colors} = require('..'); const panic = message => { console.error(message); @@ -35,9 +35,9 @@ const panic = message => { panic('decode: incorrect frame bitmap length'); } - const first = await Animation.decode(binary, {onlyFirstFrame: true}); - if (first.length !== 1) - panic('decode: onlyExtractFirstFrame returned more than one frame'); + const first = await Image.decode(binary); + if (first.constructor !== Image) + panic('decode: Image.decode should return the first frame as an Image'); } { diff --git a/tests/jxl.js b/tests/jxl.js index 260208a9..6467ca29 100644 --- a/tests/jxl.js +++ b/tests/jxl.js @@ -51,8 +51,9 @@ const difference = (bitmap, width, height, reference, step = 1, premultiplied = } // the first frame only; Animation.decode of a still image - const first = await Animation.decode(await fs.readFile('./tests/jxl/animation.jxl'), {onlyFirstFrame: true}); - if (first.length !== 1) panic(`onlyFirstFrame: ${first.length} frames`); + const animated = await fs.readFile('./tests/jxl/animation.jxl'); + const first = await Image.decode(animated); + if (first.constructor !== Image || !Buffer.from(first.bitmap).equals(Buffer.from((await Animation.decode(animated)).frames[0].bitmap))) panic('Image.decode: the first frame of an animated JPEG XL should be Animation.decode\'s first frame'); const still = await Animation.decode(await fs.readFile('./tests/jxl/lossy.jxl')); if (still.length !== 1 || still.frames[0].width !== 64) panic('a still JPEG XL as an animation'); diff --git a/tests/options-only.js b/tests/options-only.js index 088ddcee..e059ae84 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -117,7 +117,7 @@ const sameImage = (image, reference, label) => { if (Frame.from(photo, {duration: 40, xOffset: 2}).xOffset !== 2 || Frame.from(photo).duration !== 100) panic('Frame.from(image, options)'); if (new Animation([new Frame(4, 4)], {loopCount: 2}).loopCount !== 2 || new Animation([new Frame(4, 4)]).loopCount !== Infinity) panic('new Animation(frames, options)'); const encodedGIF = await new Animation([new Frame(4, 4)]).encodeGIF(); - if ((await Animation.decode(encodedGIF, {onlyFirstFrame: true})).length !== 1) panic('Animation.decode(data, { onlyFirstFrame })'); + if (!((await Image.decode(encodedGIF)) instanceof Image)) panic('Image.decode(data) should return the first frame as an Image'); const oldFrames = { 'new Frame(4, 4, 100)': () => new Frame(4, 4, 100), 'new Frame(4, 4, 100, 1, 1, "keep")': () => new Frame(4, 4, 100, 1, 1, 'keep'), @@ -128,7 +128,6 @@ const sameImage = (image, reference, label) => { 'Animation.decode(gif, true)': () => Animation.decode(encodedGIF, true), 'decode(gif, true)': () => decode(encodedGIF, true) }; - if (!((await decode(encodedGIF, {onlyFirstFrame: true})) instanceof Image)) panic('decode(data, { onlyFirstFrame }) should return an Image'); for (const [label, call] of Object.entries(oldFrames)) { const error = await rejects(call, label); if (!/takes an options object/.test(error.message)) panic(`${label}: the message should name the options form, got ${error.message}`); diff --git a/tests/options.js b/tests/options.js index a832fdf3..3d504e5b 100644 --- a/tests/options.js +++ b/tests/options.js @@ -49,8 +49,7 @@ const same = (name, a, b) => { if (!Buffer.from(await animation[method]({metadata: {author, comment: author}})).includes(author)) panic(`${method}: metadata missing`); const gif = await animation.encodeGIF(); - if ((await Animation.decode(gif, {onlyFirstFrame: true})).length !== 1) panic('decode: onlyFirstFrame not applied'); - if (!(await decode(gif, {onlyFirstFrame: true}) instanceof Image)) panic('decode: onlyFirstFrame should return an Image'); + if (!((await Image.decode(gif)) instanceof Image)) panic('Image.decode: the first frame should be an Image'); if ((await decode(gif, {})).length !== 2) panic('decode: wrong default'); // decode() passes applyOrientation on to Image.decode() diff --git a/tests/regressions.js b/tests/regressions.js index 2f730cb2..71504f4e 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -995,7 +995,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); try { await Image.decode(png(30000, 30000), {maxPixels: Infinity}); } catch (err) { error = err; } assert(error && /truncated/.test(error.message), `maxPixels: Infinity should lift the limit (${error && error.message})`); // the first frame alone fits - assert((await Animation.decode(gif, {maxPixels: 4000 * 4000 * 10, onlyFirstFrame: true})).length === 1, 'the first frame should fit the limit'); + assert((await Image.decode(gif, {maxPixels: 4000 * 4000 * 10})) instanceof Image, 'the first frame should fit the limit'); // damaged data error = null; From c235725bb9686d2103b2b403383507bbdc917411 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:33:05 +0000 Subject: [PATCH 035/188] return Uint8Array from the encoders --- src/animation-encoder.ts | 4 ++-- src/animation.ts | 10 +++++----- src/gainmap.ts | 4 ++-- src/image.ts | 20 ++++++++++---------- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/animation-encoder.ts b/src/animation-encoder.ts index eaa75b37..bb1d0e0e 100644 --- a/src/animation-encoder.ts +++ b/src/animation-encoder.ts @@ -13,7 +13,7 @@ import type { AnimationOptions, WebPAnimationEncodeOptions, GIFEncodeOptions, AP interface Format { readonly optimize: OptimizeOptions; frame(f: OptimizedFrame | InputFrame, optimized: boolean): Promise; - file(optimizer: Optimizer | null): Promise | Uint8Array; + file(optimizer: Optimizer | null): Promise> | Uint8Array; } /** @@ -79,7 +79,7 @@ export class AnimationEncoder { } /** Encodes the rest and returns the file. The encoder can't be used afterwards */ - async finish(): Promise { + async finish(): Promise> { if (this.finished) throw new Error('The animation encoder is finished'); this.finished = true; await this.queue; diff --git a/src/animation.ts b/src/animation.ts index 0e6315f8..3067ef83 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -257,8 +257,8 @@ export class Animation { * after the frames are composited * @param options See {@link GIFEncodeOptions} */ - encodeGIF(options?: GIFEncodeOptions): Promise; - async encodeGIF(options: GIFEncodeOptions = {}): Promise { + encodeGIF(options?: GIFEncodeOptions): Promise>; + async encodeGIF(options: GIFEncodeOptions = {}): Promise> { checkOptions(options, 'encodeGIF({ quality: 80 })'); const { quality = 95, metadata = {}, optimize = true } = options; const loops = fitLoops(this.loopCount, 'GIF'); @@ -287,8 +287,8 @@ export class Animation { * necessarily decode to the same frames * @param options See {@link APNGEncodeOptions} */ - encodeAPNG(options?: APNGEncodeOptions): Promise; - async encodeAPNG(options: APNGEncodeOptions = {}): Promise { + encodeAPNG(options?: APNGEncodeOptions): Promise>; + async encodeAPNG(options: APNGEncodeOptions = {}): Promise> { checkOptions(options, 'encodeAPNG({ compression: 9 })'); const { compression = 4, metadata = {}, optimize = true } = options; const loops = fitLoops(this.loopCount, 'APNG'); @@ -307,7 +307,7 @@ export class Animation { * shows the same animation, but doesn't necessarily decode to the same frames * @param options See {@link WebPAnimationEncodeOptions} */ - async encodeWebP(options: WebPAnimationEncodeOptions = {}): Promise { + async encodeWebP(options: WebPAnimationEncodeOptions = {}): Promise> { checkOptions(options, 'animation.encodeWebP({ quality: 80 })'); const { metadata = {}, optimize = true, quality, effort = 2, sharpYuv = false, alphaQuality = 100 } = options; const loops = fitLoops(this.loopCount, 'WebP'); diff --git a/src/gainmap.ts b/src/gainmap.ts index 00359cf7..fd26e46b 100644 --- a/src/gainmap.ts +++ b/src/gainmap.ts @@ -270,8 +270,8 @@ export class GainMap { * Ultra HDR JPEG * @param options See {@link GainMapEncodeOptions} */ - encode(options?: GainMapEncodeOptions): Promise; - async encode(options?: GainMapEncodeOptions): Promise { + encode(options?: GainMapEncodeOptions): Promise>; + async encode(options?: GainMapEncodeOptions): Promise> { checkOptions(options, 'encode({ quality: 80 })'); let quality = options?.quality; if (quality !== undefined) quality = Math.max(1, checkQuality(quality)); diff --git a/src/image.ts b/src/image.ts index 1d65f518..eeabee83 100644 --- a/src/image.ts +++ b/src/image.ts @@ -2577,8 +2577,8 @@ export class Image { * @param options See {@link PNGEncodeOptions} * @return The encoded data */ - encodePNG(options?: PNGEncodeOptions): Promise; - async encodePNG(options: PNGEncodeOptions = {}): Promise { + encodePNG(options?: PNGEncodeOptions): Promise>; + async encodePNG(options: PNGEncodeOptions = {}): Promise> { checkOptions(options, 'encodePNG({ compression: 9, metadata: { author: \'me\' } })'); // the metadata of 3.x passed as the only argument would be ignored: it has to go in { metadata } for (const key of Object.keys(options)) @@ -2592,8 +2592,8 @@ export class Image { * Encodes the image into a JPEG * @param options See {@link JPEGEncodeOptions} */ - encodeJPEG(options?: JPEGEncodeOptions): Promise; - async encodeJPEG(options: JPEGEncodeOptions = {}): Promise { + encodeJPEG(options?: JPEGEncodeOptions): Promise>; + async encodeJPEG(options: JPEGEncodeOptions = {}): Promise> { checkOptions(options, 'encodeJPEG({ quality: 80 })'); const { quality = 90, metadata = {}, gainMap: gainMapOption = true } = options; const m = resolveMetadata(metadata); @@ -2621,7 +2621,7 @@ export class Image { * quality * @param options See {@link WebPEncodeOptions} */ - async encodeWebP(options: WebPEncodeOptions = {}): Promise { + async encodeWebP(options: WebPEncodeOptions = {}): Promise> { checkOptions(options, 'image.encodeWebP({ quality: 80 })'); const { quality, effort = 2, sharpYuv = false, alphaQuality = 100, threads = defaultThreads(), metadata = {} } = options; checkThreads(threads); @@ -2646,14 +2646,14 @@ export class Image { * Encodes the image as BMP: with a palette if it is opaque and has at most 256 colors, else with 24 bit pixels, or * 32 bit ones if it has transparency */ - async encodeBMP(): Promise { + async encodeBMP(): Promise> { return bmp.encode(this.__bytes__(), this.width, this.height); } /** * Encodes the image as QOI (3 channels if the image is opaque) */ - async encodeQOI(): Promise { + async encodeQOI(): Promise> { return qoi.encode(this.__bytes__(), this.width, this.height); } @@ -2661,7 +2661,7 @@ export class Image { * Encodes the image as AVIF (AV1, with an alpha image if the image has transparency) * @param options See {@link AVIFEncodeOptions} */ - async encodeAVIF(options: AVIFEncodeOptions = {}): Promise { + async encodeAVIF(options: AVIFEncodeOptions = {}): Promise> { checkOptions(options, 'image.encodeAVIF({ quality: 60 })'); const { quality = 60, alphaQuality = 100, subsampling = '4:4:4', effort = 1, threads = defaultThreads(), metadata = {}, gainMap = true } = options; checkEffort(effort, 2); @@ -2704,7 +2704,7 @@ export class Image { * container, with the metadata as Exif * @param options See {@link JXLEncodeOptions} */ - async encodeJXL(options: JXLEncodeOptions = {}): Promise { + async encodeJXL(options: JXLEncodeOptions = {}): Promise> { checkOptions(options, 'image.encodeJXL({ quality: 90 })'); const { quality, effort = 1, threads = defaultThreads(), metadata = {}, gainMap = true } = options; checkEffort(effort, 2); @@ -2720,7 +2720,7 @@ export class Image { * Encodes the image as the smallest fitting Netpbm format: PBM for black and white images, PGM for greyscale ones, * PPM for others, PAM if the image has transparency */ - async encodePNM(): Promise { + async encodePNM(): Promise> { return netpbm.encode(this.__bytes__(), this.width, this.height); } From 8151cd2c1c78d59a31c4708b38448d42752ce376 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:33:05 +0000 Subject: [PATCH 036/188] add the subsampling option of encodeJPEG --- src/codecs/jpeg.ts | 6 ++++-- src/image.ts | 14 ++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/codecs/jpeg.ts b/src/codecs/jpeg.ts index 303a3abd..23ed976f 100644 --- a/src/codecs/jpeg.ts +++ b/src/codecs/jpeg.ts @@ -993,9 +993,11 @@ export interface EncodeMetadata { grey?: boolean; /** whether to keep the chroma at full resolution at any quality (else only above 90) */ fullChroma?: boolean; + /** the chroma subsampling, overriding the default by quality (4:2:0 up to 90, 4:4:4 above) */ + subsampling?: '4:4:4' | '4:2:0'; } -export function encode(pixels: Uint8Array, width: number, height: number, quality = 90, { exif, comment, segments, grey = false, fullChroma = false }: EncodeMetadata = {}): Uint8Array { +export function encode(pixels: Uint8Array, width: number, height: number, quality = 90, { exif, comment, segments, grey = false, fullChroma = false, subsampling }: 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))); @@ -1007,7 +1009,7 @@ export function encode(pixels: Uint8Array, width: number, height: number, qualit // like libjpeg-turbo (cjpeg -optimize, -sample 1x1 above 90), byte for byte: its color conversion, downsampling, // integer DCT, quantization and padding // (greyscale: like cjpeg -grayscale, one component, no subsampling) - const subsample = quality <= 90 && !grey && !fullChroma; + const subsample = !grey && !fullChroma && (subsampling ? subsampling === '4:2:0' : quality <= 90); const hmax = subsample ? 2 : 1; const mcuSize = 8 * hmax; const mcusPerLine = Math.ceil(width / mcuSize); diff --git a/src/image.ts b/src/image.ts index eeabee83..49b2dc01 100644 --- a/src/image.ts +++ b/src/image.ts @@ -280,9 +280,14 @@ export interface PNGEncodeOptions { } /** Options of {@link Image.encodeJPEG} */ -export interface JPEGEncodeOptions { - /** The JPEG quality (1-100), chroma subsampling is disabled above 90. Default: `90` */ +export interface JPEGEncodeOptions extends EncodeOptions { + /** The JPEG quality (1-100). Default: `90` */ quality?: number; + /** + * Chroma subsampling: `'4:4:4'` keeps the full color resolution, `'4:2:0'` halves it (smaller, like most + * cameras). Default: `'4:2:0'` up to quality 90, `'4:4:4'` above (like libjpeg-turbo's `cjpeg`) + */ + subsampling?: '4:4:4' | '4:2:0'; /** Metadata to include, see {@link Metadata} */ metadata?: Metadata; /** @@ -2595,9 +2600,10 @@ export class Image { encodeJPEG(options?: JPEGEncodeOptions): Promise>; async encodeJPEG(options: JPEGEncodeOptions = {}): Promise> { checkOptions(options, 'encodeJPEG({ quality: 80 })'); - const { quality = 90, metadata = {}, gainMap: gainMapOption = true } = options; + const { quality = 90, subsampling, metadata = {}, gainMap: gainMapOption = true } = options; + if (subsampling !== undefined && subsampling !== '4:4:4' && subsampling !== '4:2:0') throw new TypeError(`subsampling must be '4:4:4' or '4:2:0', not ${String(subsampling)}`); const m = resolveMetadata(metadata); - const base = { exif: exifData(m), comment: m.comment ? utf8(m.comment) : undefined }; + const base = { exif: exifData(m), comment: m.comment ? utf8(m.comment) : undefined, subsampling }; const gainMap = this.__gain_map__; if (!gainMap || gainMapOption === false) return jpeg.encode(this.__bytes__(), this.width, this.height, finite(quality, 'quality'), base); From 9bc12c7637836c69de824dbddfcf217b5ef18fe8 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:33:05 +0000 Subject: [PATCH 037/188] spell the API in American English --- guides/Migrating to 4.0.md | 2 +- src/codecs/av1/decode.ts | 4 ++-- src/codecs/av1/encode.ts | 4 ++-- src/codecs/av1/ipred.ts | 2 +- src/codecs/av1/palette.ts | 2 +- src/codecs/av1/tables.ts | 2 +- src/codecs/avif-enc.ts | 6 +++--- src/codecs/hdr.ts | 2 +- src/codecs/jpeg.ts | 44 +++++++++++++++++++------------------- src/codecs/jxl.ts | 8 +++---- src/codecs/jxl/headers.ts | 4 ++-- src/codecs/netpbm.ts | 20 ++++++++--------- src/codecs/png.ts | 44 +++++++++++++++++++------------------- src/codecs/quantize.ts | 2 +- src/codecs/tga.ts | 4 ++-- src/codecs/tiff.ts | 2 +- src/gainmap.ts | 16 +++++++------- src/image.ts | 20 ++++++++--------- tests/effects.js | 2 +- tests/gainmap.js | 2 +- tests/regressions.js | 2 +- 21 files changed, 97 insertions(+), 97 deletions(-) diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index 3e00497a..4ccc1359 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -15,7 +15,7 @@ The recommended path: 3. Switch to 4.0 and go through the three behaviour changes. The [checklist](#checklist) at the end has grep patterns for finding the code they affect. -## Behaviour changes +## Behavior changes These change what code does without a compile error. diff --git a/src/codecs/av1/decode.ts b/src/codecs/av1/decode.ts index b7e9392c..8e2d86fb 100644 --- a/src/codecs/av1/decode.ts +++ b/src/codecs/av1/decode.ts @@ -220,7 +220,7 @@ export class TileDecoder { private aPalSz: Uint8Array; private lPalSz = new Uint8Array(32); private aPalSzUv: Uint8Array; private lPalSzUv = new Uint8Array(32); private aPartition: Uint8Array; private lPartition = new Uint8Array(16); - // palettes of the neighbours: [position][plane][8] + // palettes of the neighbors: [position][plane][8] private aPal: Uint16Array; private lPal = new Uint16Array(32 * 24); private pal = new Uint16Array(24); @@ -600,7 +600,7 @@ export class TileDecoder { const segmentation = hdr.segmentation; let seg = null as null | typeof segmentation.data[0]; const readSegId = (): void => { - // predicted from the neighbours' segments + // predicted from the neighbors' segments const map = f.segMap, s = f.b4Stride, o = by * s + bx; let segCtx: number, pred: number; if (haveLeft && haveTop) { diff --git a/src/codecs/av1/encode.ts b/src/codecs/av1/encode.ts index 4cbdb7e9..3ecc3f8f 100644 --- a/src/codecs/av1/encode.ts +++ b/src/codecs/av1/encode.ts @@ -762,7 +762,7 @@ class TileEncoder { return null; } - // the mode contexts of a block (the smooth modes of the neighbours change the edges' filters) + // the mode contexts of a block (the smooth modes of the neighbors change the edges' filters) private modeContexts(bs: number, b: Block): void { const bx4 = this.bx, by4 = this.by & 31, aw = 1 << bdim(bs, 2), lh = 1 << bdim(bs, 3); this.aMode.fill(b.yMode, bx4, bx4 + aw); @@ -1286,7 +1286,7 @@ class TileEncoder { } } - // a plane's palette (Y or U): its size, the colors of the neighbours' palettes it uses, the others (increasing: + // a plane's palette (Y or U): its size, the colors of the neighbors' palettes it uses, the others (increasing: // the first one's bits, then the differences); like the decoder's readPalPlane private writePalPlane(b: Block, pl: number, szCtx: number, bx4: number, by4: number): void { const w = this.w as SymbolEncoder, palSz = b.palSz[pl]; diff --git a/src/codecs/av1/ipred.ts b/src/codecs/av1/ipred.ts index 3f3fcbdd..acb07efb 100644 --- a/src/codecs/av1/ipred.ts +++ b/src/codecs/av1/ipred.ts @@ -10,7 +10,7 @@ import { SM_WEIGHTS, DR_INTRA_DERIVATIVE, FILTER_INTRA_TAPS } from './tables.ts'; -/** Flags or'd into the angle: filter the edges, the edges are smooth (a neighbour uses smooth prediction) */ +/** Flags or'd into the angle: filter the edges, the edges are smooth (a neighbor uses smooth prediction) */ export const ANGLE_USE_EDGE_FILTER = 1024, ANGLE_SMOOTH_EDGE = 512; /** The edge buffer: the top left pixel at EDGE, the top (and top right) after it, the left (and bottom left) before */ diff --git a/src/codecs/av1/palette.ts b/src/codecs/av1/palette.ts index e9d5d9e0..9aafaf02 100644 --- a/src/codecs/av1/palette.ts +++ b/src/codecs/av1/palette.ts @@ -44,7 +44,7 @@ const ORDER = new Uint8Array(8); /** * Visits the color indices of a palette block (w x h of t, rows of stride) after the first, in the decoder's wavefront - * order: with the context and the index's rank in the order of its neighbours' colors (the symbol) + * order: with the context and the index's rank in the order of its neighbors' colors (the symbol) */ export function colorRanks(t: Uint8Array, stride: number, w: number, h: number, visit: (ctx: number, rank: number) => void): void { const order = ORDER; diff --git a/src/codecs/av1/tables.ts b/src/codecs/av1/tables.ts index f186a0eb..6af1aa6e 100644 --- a/src/codecs/av1/tables.ts +++ b/src/codecs/av1/tables.ts @@ -26,7 +26,7 @@ export const TX_4X4 = 0, TX_8X8 = 1, TX_16X16 = 2, TX_32X32 = 3, TX_64X64 = 4, R export const DCT_DCT = 0, IDTX = 9, WHT_WHT = 16; export const TX_CLASS_2D = 0, TX_CLASS_H = 1, TX_CLASS_V = 2; -// which neighbouring edges an intra predicted block can use +// which neighboring edges an intra predicted block can use export const EDGE_I444_TOP_HAS_RIGHT = 1, EDGE_I422_TOP_HAS_RIGHT = 2, EDGE_I420_TOP_HAS_RIGHT = 4, EDGE_I444_LEFT_HAS_BOTTOM = 8, EDGE_I422_LEFT_HAS_BOTTOM = 16, EDGE_I420_LEFT_HAS_BOTTOM = 32, EDGE_ALL_TOP_HAS_RIGHT = 7, EDGE_ALL_LEFT_HAS_BOTTOM = 56, EDGE_ALL_TR_AND_BL = 63; diff --git a/src/codecs/avif-enc.ts b/src/codecs/avif-enc.ts index e14512e4..8e351cd2 100644 --- a/src/codecs/avif-enc.ts +++ b/src/codecs/avif-enc.ts @@ -22,7 +22,7 @@ export interface AVIFOptions { exif?: Uint8Array; /** at most this many worker threads (1: none) */ threads: number; - /** a gain map (RGBA, grey for one for all channels), written as a tone mapped derived item like libavif does */ + /** a gain map (RGBA, gray for one for all channels), written as a tone mapped derived item like libavif does */ gainMap?: { pixels: Uint8Array, width: number, height: number, params: Params, quality: number }; } @@ -118,7 +118,7 @@ function fillTransparent(rgba: Uint8Array, width: number, height: number): Uint8 } colors = next; } - // (the transparent pixels smoothed: a few passes of averaging with their neighbours) + // (the transparent pixels smoothed: a few passes of averaging with their neighbors) const unknown: number[] = []; for (let i = 0; i < width * height; i++) if (!rgba[4 * i + 3]) unknown.push(i); for (let pass = 0; pass < 4; pass++) { @@ -218,7 +218,7 @@ export async function encode(rgba: Uint8Array, width: number, height: number, { return encodeAV1(image, params); }; let color: Uint8Array, alpha: Uint8Array | null = null, map: Uint8Array | null = null; - // (the gain map: monochrome if grey, else 4:4:4, its values as they are, no transfer) + // (the gain map: monochrome if gray, else 4:4:4, its values as they are, no transfer) let mapGray = true; if (gainMap) for (let i = 0; i < gainMap.pixels.length; i += 4) if (gainMap.pixels[i] !== gainMap.pixels[i + 1] || gainMap.pixels[i] !== gainMap.pixels[i + 2]) { mapGray = false; break; } try { diff --git a/src/codecs/hdr.ts b/src/codecs/hdr.ts index f347eb8d..ef456344 100644 --- a/src/codecs/hdr.ts +++ b/src/codecs/hdr.ts @@ -16,7 +16,7 @@ function linearToSRGB(v: number): number { /** * Linear light RGB (3 values per pixel) as sRGB RGBA. Reinhard's extended operator ("Photographic Tone Reproduction - * for Digital Images", 2002, equation 4) without its automatic exposure, which makes dark scenes like nights grey: + * for Digital Images", 2002, equation 4) without its automatic exposure, which makes dark scenes like nights gray: * the luminance L becomes L (1 + L / Lw²) / (1 + L), the brightest pixel's (at least 1) Lw white, and the channels are * scaled by that, keeping their hue; images no brighter than white stay as they are */ diff --git a/src/codecs/jpeg.ts b/src/codecs/jpeg.ts index 23ed976f..a05ec45d 100644 --- a/src/codecs/jpeg.ts +++ b/src/codecs/jpeg.ts @@ -19,7 +19,7 @@ const ZIGZAG = Int32Array.of( 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63 ); -type Transform = 'grey' | 'rgb' | 'ycc' | 'ycck' | 'cmyk'; +type Transform = 'gray' | 'rgb' | 'ycc' | 'ycck' | 'cmyk'; interface HuffmanTable { /** (length << 8 | symbol) for codes up to 9 bits, 0 = slow path */ @@ -47,7 +47,7 @@ interface Component { * which the decoder turns into `plane` (the samples) as soon as the row is complete */ coefficients: Int16Array; - /** sequential: the sample plane, and which of its block rows are done (the others stay mid grey) */ + /** sequential: the sample plane, and which of its block rows are done (the others stay mid gray) */ plane?: Uint8Array; done?: Uint8Array; pred: number; @@ -85,7 +85,7 @@ function clamp8(v: number): number { /** * Decodes a JPEG into 8-bit RGBA pixel data * Supports baseline, extended and progressive huffman coded JPEGs with any - * sampling factors, restart intervals and greyscale, YCbCr, RGB, CMYK and YCCK color + * sampling factors, restart intervals and grayscale, YCbCr, RGB, CMYK and YCCK color * The output matches libjpeg's defaults (islow IDCT, fancy upsampling) * @param {Uint8Array} data * @param {{transform?: 'ycc'|'rgb'|'cmyk'}} [options] overrides the color transform detected from JFIF/Adobe markers (used by TIFF) @@ -257,7 +257,7 @@ function createFrame(progressive: boolean, height: number, width: number, compon } if (!width || !sampling.length) throw new Error('JPEG: invalid dimensions'); - // (grey, YCbCr/RGB and CMYK have up to 4) + // (gray, YCbCr/RGB and CMYK have up to 4) if (sampling.length > 4) throw new Error(`JPEG: ${sampling.length} components are not supported`); const hmax = Math.max(...sampling.map(c => c.h)); @@ -709,7 +709,7 @@ function idct(coefficients: Int16Array, offset: number, quantization: Int32Array // runs the IDCT over every block, producing the component's sample plane function buildPlane(component: Component): { plane: Uint8Array, stride: number } { const stride = component.blocksPerLineForMcu * 8; - // sequential: already rendered, the rows no scan reached are mid grey (what blocks without coefficients render to) + // sequential: already rendered, the rows no scan reached are mid gray (what blocks without coefficients render to) if (component.plane && component.done) { const { plane, done } = component; for (let row = 0; row < component.blocksPerColumn; row++) @@ -718,7 +718,7 @@ function buildPlane(component: Component): { plane: Uint8Array, stride: number } } const plane = new Uint8Array(stride * component.blocksPerColumnForMcu * 8); const workspace = new Int32Array(64); - // components without any scan data decode to mid grey + // components without any scan data decode to mid gray const quantization = component.quantization || new Int32Array(64); for (let row = 0; row < component.blocksPerColumn; row++) @@ -834,7 +834,7 @@ function output(frame: Frame, adobe: { transform: number } | null, jfif: boolean const count = components.length; let transform: Transform; - if (count === 1) transform = 'grey'; + if (count === 1) transform = 'gray'; else if (override && (count === 4) === (override === 'cmyk')) transform = override; else if (count === 3) { if (adobe) transform = adobe.transform === 0 ? 'rgb' : 'ycc'; @@ -846,15 +846,15 @@ function output(frame: Frame, adobe: { transform: number } | null, jfif: boolean const [p0, p1, p2, p3] = planes; // one loop per transform: deciding per pixel costs more than the conversion - if (transform === 'grey' || transform === 'rgb' || transform === 'ycc') { + if (transform === 'gray' || transform === 'rgb' || transform === 'ycc') { const s0 = p0.samples, s1 = (p1 ?? p0).samples, s2 = (p2 ?? p0).samples; - const grey = transform === 'grey', ycc = transform === 'ycc'; + const gray = transform === 'gray', ycc = transform === 'ycc'; // (YCbCr a pixel at a time as a 32-bit word, little endian) const words = littleEndian ? new Uint32Array(pixels.buffer) : null; for (let y = 0, o = 0; y < height; y++, o = y * width * 4) { for (const p of planes) p.fill?.(y); const r0 = y * p0.stride, r1 = p1 ? y * p1.stride : r0, r2 = p2 ? y * p2.stride : r0; - if (grey) for (let x = 0; x < width; x++, o += 4) { + if (gray) for (let x = 0; x < width; x++, o += 4) { pixels[o] = pixels[o + 1] = pixels[o + 2] = s0[r0 + x]; pixels[o + 3] = 0xff; } else if (ycc && words) for (let x = 0, w = o >> 2; x < width; x++, w++) { @@ -989,15 +989,15 @@ export interface EncodeMetadata { comment?: Uint8Array; /** whole marker segments written after these (e.g. a gain map's XMP, ISO 21496-1 and MPF segments) */ segments?: Uint8Array[]; - /** a greyscale JPEG (one component, the luma of the pixels) */ - grey?: boolean; + /** a grayscale JPEG (one component, the luma of the pixels) */ + gray?: boolean; /** whether to keep the chroma at full resolution at any quality (else only above 90) */ fullChroma?: boolean; /** the chroma subsampling, overriding the default by quality (4:2:0 up to 90, 4:4:4 above) */ subsampling?: '4:4:4' | '4:2:0'; } -export function encode(pixels: Uint8Array, width: number, height: number, quality = 90, { exif, comment, segments, grey = false, fullChroma = false, subsampling }: EncodeMetadata = {}): Uint8Array { +export function encode(pixels: Uint8Array, width: number, height: number, quality = 90, { exif, comment, segments, gray = false, fullChroma = false, subsampling }: 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))); @@ -1008,8 +1008,8 @@ export function encode(pixels: Uint8Array, width: number, height: number, qualit // like libjpeg-turbo (cjpeg -optimize, -sample 1x1 above 90), byte for byte: its color conversion, downsampling, // integer DCT, quantization and padding - // (greyscale: like cjpeg -grayscale, one component, no subsampling) - const subsample = !grey && !fullChroma && (subsampling ? subsampling === '4:2:0' : quality <= 90); + // (grayscale: like cjpeg -grayscale, one component, no subsampling) + const subsample = !gray && !fullChroma && (subsampling ? subsampling === '4:2:0' : quality <= 90); const hmax = subsample ? 2 : 1; const mcuSize = 8 * hmax; const mcusPerLine = Math.ceil(width / mcuSize); @@ -1071,13 +1071,13 @@ export function encode(pixels: Uint8Array, width: number, height: number, qualit { plane: Cb, stride: CW, h: 1, v: 1, blocksX: chromaBlocksX, blocksY: chromaBlocksY, table: 1 }, { plane: Cr, stride: CW, h: 1, v: 1, blocksX: chromaBlocksX, blocksY: chromaBlocksY, table: 1 } ]; - const componentCount = grey ? 1 : 3; + const componentCount = gray ? 1 : 3; // quantized blocks in MCU order (Y blocks, then Cb, then Cr), their coefficients in zigzag order, with the index // of each block's last nonzero coefficient. MCUs reaching past a component's blocks get dummy blocks (jccoefct.c): // without AC coefficients, with the DC coefficient of the block before (below the image: of the MCU's block // before the row) - const blocksPerMcu = grey ? 1 : hmax * hmax + 2; + const blocksPerMcu = gray ? 1 : hmax * hmax + 2; const count = mcusPerLine * mcusPerColumn * blocksPerMcu; const coefficients = new Int16Array(count * 64); const owners = new Uint8Array(count), ends = new Uint8Array(count); @@ -1125,7 +1125,7 @@ export function encode(pixels: Uint8Array, width: number, height: number, qualit const frequencies = [new Int32Array(257), new Int32Array(257), new Int32Array(257), new Int32Array(257)]; countSymbols(blocks, frequencies); - const huffman = frequencies.slice(0, grey ? 2 : 4).map(buildOptimalTable); + const huffman = frequencies.slice(0, gray ? 2 : 4).map(buildOptimalTable); const writer = new BitWriter(width * height); writer.bytes(0xff, 0xd8); @@ -1142,20 +1142,20 @@ export function encode(pixels: Uint8Array, width: number, height: number, qualit if (segments) for (const bytes of segments) for (let i = 0; i < bytes.length; i++) writer.bytes(bytes[i]); // DQT, a segment per table like libjpeg - for (let t = 0; t < (grey ? 1 : 2); t++) { + for (let t = 0; t < (gray ? 1 : 2); t++) { writer.bytes(0xff, 0xdb, 0x00, 0x43, t); for (let k = 0; k < 64; k++) writer.bytes(tables[t][ZIGZAG[k]]); } // SOF0 - if (grey) writer.bytes(0xff, 0xc0, 0x00, 0x0b, 0x08, height >> 8, height & 0xff, width >> 8, width & 0xff, 0x01, 1, 0x11, 0); + if (gray) writer.bytes(0xff, 0xc0, 0x00, 0x0b, 0x08, height >> 8, height & 0xff, width >> 8, width & 0xff, 0x01, 1, 0x11, 0); else { writer.bytes(0xff, 0xc0, 0x00, 0x11, 0x08, height >> 8, height & 0xff, width >> 8, width & 0xff, 0x03); writer.bytes(1, hmax << 4 | hmax, 0, 2, 0x11, 1, 3, 0x11, 1); } // DHT: luma DC, luma AC, chroma DC, chroma AC - for (const [index, id] of ([[0, 0x00], [1, 0x10], [2, 0x01], [3, 0x11]] as Array<[number, number]>).slice(0, grey ? 2 : 4)) { + for (const [index, id] of ([[0, 0x00], [1, 0x10], [2, 0x01], [3, 0x11]] as Array<[number, number]>).slice(0, gray ? 2 : 4)) { const { bits, values } = huffman[index]; const length = 19 + values.length; writer.bytes(0xff, 0xc4, length >> 8, length & 0xff, id); @@ -1164,7 +1164,7 @@ export function encode(pixels: Uint8Array, width: number, height: number, qualit } // SOS - if (grey) writer.bytes(0xff, 0xda, 0x00, 0x08, 0x01, 1, 0x00, 0x00, 0x3f, 0x00); + if (gray) writer.bytes(0xff, 0xda, 0x00, 0x08, 0x01, 1, 0x00, 0x00, 0x3f, 0x00); else writer.bytes(0xff, 0xda, 0x00, 0x0c, 0x03, 1, 0x00, 2, 0x11, 3, 0x11, 0x00, 0x3f, 0x00); writeBlocks(blocks, huffman, writer); diff --git a/src/codecs/jxl.ts b/src/codecs/jxl.ts index 9bd8e924..ae096785 100644 --- a/src/codecs/jxl.ts +++ b/src/codecs/jxl.ts @@ -8,7 +8,7 @@ import { startThreads } from '../util/threads.ts'; import { MAX_PIXELS, checkPixels } from '../util/limits.ts'; import { BitReader } from './jxl/bits.ts'; import { - CS_GREY, CS_RGB, decodeICC, imageHeader, PRI_BT2100, PRI_CUSTOM, PRI_P3, readICC, TF_DCI, TF_HLG, TF_LINEAR, TF_PQ, + CS_GRAY, CS_RGB, decodeICC, imageHeader, PRI_BT2100, PRI_CUSTOM, PRI_P3, readICC, TF_DCI, TF_HLG, TF_LINEAR, TF_PQ, WP_CUSTOM, WP_DCI, WP_E, type ColourEncoding, type ImageHeader } from './jxl/headers.ts'; import { frameHeader, toc } from './jxl/frame-header.ts'; @@ -120,15 +120,15 @@ const PRIMARIES: Record = { */ export function colorSpace(image: ImageHeader): ColorSpace | undefined { const c: ColourEncoding = image.colour; - if (image.xybEncoded || c.wantICC || (c.colourSpace !== CS_RGB && c.colourSpace !== CS_GREY)) return; + if (image.xybEncoded || c.wantICC || (c.colourSpace !== CS_RGB && c.colourSpace !== CS_GRAY)) return; if (c.transfer === TF_PQ || c.transfer === TF_HLG) return; const curve: Curve | null = c.transfer === -1 ? (c.gamma > 0 ? v => v ** (1 / c.gamma) : null) : c.transfer === TF_LINEAR ? v => v : c.transfer === TF_DCI ? v => v ** 2.6 : null; const white = c.whitePoint === WP_CUSTOM ? c.white : c.whitePoint === WP_E ? [1 / 3, 1 / 3] : c.whitePoint === WP_DCI ? [0.314, 0.351] : null; - const primaries = c.colourSpace === CS_GREY ? null : c.primaries === PRI_CUSTOM + const primaries = c.colourSpace === CS_GRAY ? null : c.primaries === PRI_CUSTOM ? [0, 2, 4].map(i => c.primariesXY.slice(i, i + 2)) : PRIMARIES[c.primaries] ?? null; // (sRGB: nothing to convert; gray only by its curve) - if (c.colourSpace === CS_GREY) return curve ? { xy: null, curve } : undefined; + if (c.colourSpace === CS_GRAY) return curve ? { xy: null, curve } : undefined; if (!curve && !white && !primaries) return; return { xy: [...primaries ?? [[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]], white ?? WHITE_D65], curve: curve ?? srgbDecode }; } diff --git a/src/codecs/jxl/headers.ts b/src/codecs/jxl/headers.ts index f7d3bd80..bd1ab94f 100644 --- a/src/codecs/jxl/headers.ts +++ b/src/codecs/jxl/headers.ts @@ -24,7 +24,7 @@ export interface ExtraChannel { spot: number[]; } -export const CS_RGB = 0, CS_GREY = 1, CS_XYB = 2; +export const CS_RGB = 0, CS_GRAY = 1, CS_XYB = 2; export const WP_D65 = 1, WP_CUSTOM = 2, WP_E = 10, WP_DCI = 11; export const PRI_SRGB = 1, PRI_CUSTOM = 2, PRI_BT2100 = 9, PRI_P3 = 11; export const TF_BT709 = 1, TF_UNKNOWN = 2, TF_LINEAR = 8, TF_SRGB = 13, TF_PQ = 16, TF_DCI = 17, TF_HLG = 18; @@ -158,7 +158,7 @@ export function colourEncoding(r: BitReader): ColourEncoding { if (c.whitePoint === WP_CUSTOM) c.white = [customXY(r), customXY(r)]; else if (c.whitePoint !== WP_D65 && c.whitePoint !== WP_E && c.whitePoint !== WP_DCI) throw new Error('JPEG XL: invalid white point'); } - if (c.colourSpace !== CS_XYB && c.colourSpace !== CS_GREY) { + if (c.colourSpace !== CS_XYB && c.colourSpace !== CS_GRAY) { c.primaries = r.enum(); if (c.primaries === PRI_CUSTOM) for (let i = 0; i < 6; i++) c.primariesXY.push(customXY(r)); else if (c.primaries !== PRI_SRGB && c.primaries !== PRI_BT2100 && c.primaries !== PRI_P3) throw new Error('JPEG XL: invalid primaries'); diff --git a/src/codecs/netpbm.ts b/src/codecs/netpbm.ts index 75ba16b9..5fd8df64 100644 --- a/src/codecs/netpbm.ts +++ b/src/codecs/netpbm.ts @@ -126,7 +126,7 @@ export function decode(d: Uint8Array, maxPixels = MAX_PIXELS, toneMapping: ToneM for (let i = 0, o = header.data; i < samples.length; i++, o += wide ? 2 : 1) samples[i] = scale(wide ? d[o] << 8 | d[o + 1] : d[o]); } - // greyscale, greyscale and alpha, RGB, RGBA (by the depth; PAM's tuple types say the same) + // grayscale, grayscale and alpha, RGB, RGBA (by the depth; PAM's tuple types say the same) const pixels = new Uint8Array(count * 4); for (let i = 0; i < count; i++) { const s = i * depth, p = i * 4; @@ -165,32 +165,32 @@ function decodePFM(d: Uint8Array, channels: number, maxPixels: number, toneMappi } /** - * Encodes RGBA pixels into the smallest fitting Netpbm format: PBM for black and white images, PGM for greyscale - * ones, PPM, and PAM (greyscale or RGB with alpha) for images with transparency + * Encodes RGBA pixels into the smallest fitting Netpbm format: PBM for black and white images, PGM for grayscale + * ones, PPM, and PAM (grayscale or RGB with alpha) for images with transparency */ export function encode(rgba: Uint8Array, width: number, height: number): Uint8Array { if (width < 1 || height < 1) throw new RangeError('Netpbm: dimensions must be at least 1'); const count = width * height; - let opaque = true, grey = true, bitmap = true; + let opaque = true, gray = true, bitmap = true; for (let p = 0; p < count * 4; p += 4) { const r = rgba[p], g = rgba[p + 1], b = rgba[p + 2], a = rgba[p + 3]; if (a !== 255) opaque = false; - if (r !== g || g !== b) grey = false; + if (r !== g || g !== b) gray = false; if (r !== 0 && r !== 255 || r !== g || g !== b) bitmap = false; - if (!opaque && !grey) break; + if (!opaque && !gray) break; } const ascii = (text: string): Uint8Array => Uint8Array.from(text, c => c.charCodeAt(0)); let header: Uint8Array, depth: number; if (!opaque) { - depth = grey ? 2 : 4; - header = ascii(`P7\nWIDTH ${width}\nHEIGHT ${height}\nDEPTH ${depth}\nMAXVAL 255\nTUPLTYPE ${grey ? 'GRAYSCALE_ALPHA' : 'RGB_ALPHA'}\nENDHDR\n`); + depth = gray ? 2 : 4; + header = ascii(`P7\nWIDTH ${width}\nHEIGHT ${height}\nDEPTH ${depth}\nMAXVAL 255\nTUPLTYPE ${gray ? 'GRAYSCALE_ALPHA' : 'RGB_ALPHA'}\nENDHDR\n`); } else if (bitmap) { depth = 0; header = ascii(`P4\n${width} ${height}\n`); } else { - depth = grey ? 1 : 3; - header = ascii(`P${grey ? 5 : 6}\n${width} ${height}\n255\n`); + depth = gray ? 1 : 3; + header = ascii(`P${gray ? 5 : 6}\n${width} ${height}\n255\n`); } const stride = depth ? width * depth : width + 7 >> 3; diff --git a/src/codecs/png.ts b/src/codecs/png.ts index ebcc677b..d60b634a 100644 --- a/src/codecs/png.ts +++ b/src/codecs/png.ts @@ -38,7 +38,7 @@ export interface EncodeOptions { palette?: Uint8Array; /** alpha per palette entry */ transparency?: Uint8Array; - /** whether to paeth filter the rows (pays off for truecolor and greyscale images) */ + /** whether to paeth filter the rows (pays off for truecolor and grayscale images) */ filter?: boolean; } @@ -110,7 +110,7 @@ function filter(data: Uint8Array, rowBytes: number, y0: number, y1: number, bpp: const row = y * rowBytes, o = (y - y0) * (rowBytes + 1) + 1; out[o - 1] = 4; - // the first row has no row above, the first pixel of every row no left neighbour + // the first row has no row above, the first pixel of every row no left neighbor if (y === 0) { for (let i = 0; i < rowBytes; i++) out[o + i] = data[row + i] - (i >= bpp ? data[row + i - bpp] : 0); continue; @@ -183,11 +183,11 @@ export interface PixelFormat { /** * Picks the smallest lossless format that fits all the given RGBA images: a palette for up to 256 colors, - * otherwise greyscale or RGB, with alpha only if a pixel isn't opaque + * otherwise grayscale or RGB, with alpha only if a pixel isn't opaque */ /** * How much more an opaque image costs per pixel with transparent pixels than without them, given the pixel formats - * (palette, grey or truecolor) both get: e.g. 4/3 for RGBA instead of RGB, 4 for RGBA instead of a palette when the + * (palette, gray or truecolor) both get: e.g. 4/3 for RGBA instead of RGB, 4 for RGBA instead of a palette when the * transparent pixels are the 257th color, 2 for 8 instead of 4 bit palette indices when they're the 17th * @param images RGBA, only the transparent pixels aren't opaque */ @@ -199,7 +199,7 @@ export function alphaCost(images: Uint8Array[]): number { /** {@link alphaCost}, of images given one at a time */ export class AlphaCost { - private grey = true; + private gray = true; private colors = new Set(); private done = false; @@ -214,10 +214,10 @@ export class AlphaCost { previous = value; const o = i * 4; if (rgba[o + 3] === 0) continue; - if (rgba[o] !== rgba[o + 1] || rgba[o] !== rgba[o + 2]) this.grey = false; + if (rgba[o] !== rgba[o + 1] || rgba[o] !== rgba[o + 2]) this.gray = false; if (colors.size <= 256) colors.add(value); - // more than 256 colors and not grey: truecolor either way, nothing else can change - else if (!this.grey) { + // more than 256 colors and not gray: truecolor either way, nothing else can change + else if (!this.gray) { this.done = true; return; } @@ -225,11 +225,11 @@ export class AlphaCost { } cost(): number { - const { colors, grey } = this; - // bytes per pixel: palette 1/8 to 1 (by its bit depth), grey 1 (with alpha 2), truecolor 3 (with alpha 4) + const { colors, gray } = this; + // bytes per pixel: palette 1/8 to 1 (by its bit depth), gray 1 (with alpha 2), truecolor 3 (with alpha 4) const palette = (entries: number): number => (entries <= 2 ? 1 : entries <= 4 ? 2 : entries <= 16 ? 4 : 8) / 8; - const transparent = colors.size < 256 ? palette(colors.size + 1) : grey ? 2 : 4; - const opaque = colors.size <= 256 ? palette(colors.size) : grey ? 1 : 3; + const transparent = colors.size < 256 ? palette(colors.size + 1) : gray ? 2 : 4; + const opaque = colors.size <= 256 ? palette(colors.size) : gray ? 1 : 3; return transparent / opaque; } } @@ -243,7 +243,7 @@ function pixelFormat(images: Uint8Array[]): PixelFormat { /** {@link pixelFormat} of images given one at a time */ export class FormatScanner { private opaque = true; - private grey = true; + private gray = true; // up to 256 distinct colors, by their first appearance private colors: Map | null = new Map(); @@ -258,22 +258,22 @@ export class FormatScanner { const o = i * 4; if (rgba[o + 3] !== 255) this.opaque = false; - if (rgba[o] !== rgba[o + 1] || rgba[o] !== rgba[o + 2]) this.grey = false; + if (rgba[o] !== rgba[o + 1] || rgba[o] !== rgba[o + 2]) this.gray = false; if (this.colors && !this.colors.has(value)) { if (this.colors.size === 256) this.colors = null; else this.colors.set(value, this.colors.size); } // nothing left to find out: RGBA it is - if (!this.colors && !this.opaque && !this.grey) return; + if (!this.colors && !this.opaque && !this.gray) return; } } format(): PixelFormat { - return formatOf(this.colors, this.opaque, this.grey); + return formatOf(this.colors, this.opaque, this.gray); } } -function formatOf(colors: Map | null, opaque: boolean, grey: boolean): PixelFormat { +function formatOf(colors: Map | null, opaque: boolean, gray: boolean): PixelFormat { if (colors) { // opaque entries are moved to the end, so tRNS can be cut short const entries = [...colors.keys()].map(value => new Uint8Array(new Uint32Array([value]).buffer)); @@ -311,7 +311,7 @@ function formatOf(colors: Map | null, opaque: boolean, grey: boo }; } - const channels = (grey ? 1 : 3) + (opaque ? 0 : 1); + const channels = (gray ? 1 : 3) + (opaque ? 0 : 1); return { channels, depth: 8, convert(rgba, width, height) { @@ -846,7 +846,7 @@ function unfilter(data: Uint8Array, offset: number, height: number, rowBytes: nu const out = inPlace ? data.subarray(offset, offset + height * rowBytes) : new Uint8Array(height * rowBytes); const first = Math.min(bpp, rowBytes); - // the first row and the first pixel of every row have zero neighbours, those are handled by separate loops + // the first row and the first pixel of every row have zero neighbors, those are handled by separate loops for (let y = 0; y < height; y++) { const filter = data[offset++]; const row = y * rowBytes; @@ -868,11 +868,11 @@ function unfilter(data: Uint8Array, offset: number, height: number, rowBytes: nu } } else if (filter === 4) { if (y === 0) { - // paeth with no row above picks the left neighbour + // paeth with no row above picks the left neighbor out.set(data.subarray(offset, offset + first), row); for (let i = row + bpp; i < row + rowBytes; i++) out[i] = data[src + i] + out[i - bpp]; } else { - // and with no left neighbour the one above + // and with no left neighbor the one above for (let i = row; i < row + first; i++) out[i] = data[src + i] + out[i - rowBytes]; if (bpp === 3 || bpp === 4) paethRow(data, src, out, row, rowBytes, bpp); else for (let i = row + bpp; i < row + rowBytes; i++) { @@ -891,7 +891,7 @@ function unfilter(data: Uint8Array, offset: number, height: number, rowBytes: nu return out; } -// the paeth filter on a row of 3 or 4 byte pixels, the left and upper left neighbours in variables: as above, with each +// the paeth filter on a row of 3 or 4 byte pixels, the left and upper left neighbors in variables: as above, with each // channel's predictor not waiting for the byte just stored function paethRow(data: Uint8Array, src: number, out: Uint8Array, row: number, rowBytes: number, bpp: number): void { const up = row - rowBytes, end = row + rowBytes; diff --git a/src/codecs/quantize.ts b/src/codecs/quantize.ts index 2b0dfae1..95b2cb0a 100644 --- a/src/codecs/quantize.ts +++ b/src/codecs/quantize.ts @@ -162,7 +162,7 @@ const ATKINSON = [[1, 0, 1 / 8], [2, 0, 1 / 8], [-1, 1, 1 / 8], [0, 1, 1 / 8], [ /** * Replaces the RGB of every pixel with an alpha above 0 by its nearest palette entry (packed RGB), in place. Pixels * with alpha 0 are left alone. Dithering: `'floyd-steinberg'` and `'atkinson'` spread each pixel's rounding error to - * the neighbours that come after it (serpentine: rows alternate direction), `'ordered'` offsets each pixel by a 4x4 + * the neighbors that come after it (serpentine: rows alternate direction), `'ordered'` offsets each pixel by a 4x4 * Bayer threshold. `strength` (0-1) scales the spread error and the threshold offset; 0 changes nothing but rounding */ export function applyPalette(pixels: Uint8Array, width: number, height: number, palette: Uint8Array, dither: 'none' | 'floyd-steinberg' | 'atkinson' | 'ordered', strength: number): void { diff --git a/src/codecs/tga.ts b/src/codecs/tga.ts index 16462973..cc7c6949 100644 --- a/src/codecs/tga.ts +++ b/src/codecs/tga.ts @@ -1,7 +1,7 @@ import { encode as utf8 } from '../util/utf8.ts'; import { MAX_PIXELS, checkPixels } from '../util/limits.ts'; -// Truevision TGA: color mapped, truecolor and greyscale images, uncompressed or run length encoded +// Truevision TGA: color mapped, truecolor and grayscale images, uncompressed or run length encoded const SIGNATURE = 'TRUEVISION-XFILE.\0'; @@ -68,7 +68,7 @@ export function isTGA(d: Uint8Array): boolean { return h.imageType & 8 ? d.length > dataStart : d.length >= dataStart + pixelBytes; } -// reads a 15/16/24/32 bit color (BGR(A), 5 bits per channel for 15/16), or 8 bit grey (color maps) +// reads a 15/16/24/32 bit color (BGR(A), 5 bits per channel for 15/16), or 8 bit gray (color maps) function readColor(d: Uint8Array, o: number, bits: number, alphaBits: number, out: Uint8Array, p: number): void { if (bits === 8) { out[p] = out[p + 1] = out[p + 2] = d[o]; diff --git a/src/codecs/tiff.ts b/src/codecs/tiff.ts index 825e05bf..81b03993 100644 --- a/src/codecs/tiff.ts +++ b/src/codecs/tiff.ts @@ -37,7 +37,7 @@ const COMPRESSION_NAMES: Record = { 2: 'CCITT RLE', 3: 'CCITT fa * Decodes the first image of a TIFF into 8-bit RGBA pixel data * Supports classic and BigTIFF in both byte orders, strips and tiles, chunky and planar data, * no/LZW/deflate/PackBits/JPEG compression, horizontal and floating point predictors, - * 1-32 bit integer and 32/64 bit float samples (auto-ranged), greyscale, palette, RGB, CMYK and YCbCr images with alpha + * 1-32 bit integer and 32/64 bit float samples (auto-ranged), grayscale, palette, RGB, CMYK and YCbCr images with alpha * @param {Uint8Array} data * @return {{width: number, height: number, pixels: Uint8Array}} */ diff --git a/src/gainmap.ts b/src/gainmap.ts index fd26e46b..8db3be18 100644 --- a/src/gainmap.ts +++ b/src/gainmap.ts @@ -69,7 +69,7 @@ export class GainMap { /** * Creates a gain map - * @param image The map's pixels: grey (red, green and blue the same) for one map for all channels. Its alpha is + * @param image The map's pixels: gray (red, green and blue the same) for one map for all channels. Its alpha is * ignored * @param options See {@link GainMapOptions} */ @@ -110,12 +110,12 @@ export class GainMap { } /** - * @internal from decoded pixels (an AVIF's or JPEG XL's map); `grey`: a map whose channels differ by at most 2 - * levels anywhere is made grey (a grey map stored in color, like libjxl writes them: one channel when written) + * @internal from decoded pixels (an AVIF's or JPEG XL's map); `gray`: a map whose channels differ by at most 2 + * levels anywhere is made gray (a gray map stored in color, like libjxl writes them: one channel when written) */ - static __from_pixels__(image: Image, params: codec.Params, grey = true): GainMap { + static __from_pixels__(image: Image, params: codec.Params, gray = true): GainMap { const px = image.bitmap; - if (grey) { + if (gray) { let spread = 0; for (let i = 0; i < px.length && spread <= 2; i += 4) spread = Math.max(spread, Math.abs(px[i] - px[i + 1]), Math.abs(px[i] - px[i + 2])); if (spread > 0 && spread <= 2) for (let i = 0; i < px.length; i += 4) px[i] = px[i + 1] = px[i + 2] = Math.round((px[i] + px[i + 1] + px[i + 2]) / 3); @@ -183,7 +183,7 @@ export class GainMap { return (this.__pixels__ as Image).height; } - /** `1` for a grey map (one boost for red, green and blue), `3` for one per channel */ + /** `1` for a gray map (one boost for red, green and blue), `3` for one per channel */ get channels(): 1 | 3 { const bitmap = (this.__pixels__ as Image).bitmap; for (let i = 0; i < bitmap.length; i += 4) if (bitmap[i] !== bitmap[i + 1] || bitmap[i] !== bitmap[i + 2]) return 3; @@ -207,8 +207,8 @@ export class GainMap { const bitmap = image.bitmap; const pixels = new Uint8Array(bitmap.buffer, bitmap.byteOffset, bitmap.byteLength); // (3 channels: their differences are the point, so the chroma keeps its resolution) - const grey = this.channels === 1; - return jpeg.encode(pixels, image.width, image.height, quality ?? 90, { grey, fullChroma: !grey }); + const gray = this.channels === 1; + return jpeg.encode(pixels, image.width, image.height, quality ?? 90, { gray, fullChroma: !gray }); } /** @internal the stored values (0-255, per channel) that boost by nothing (log2 gain 0), clamped to the range */ diff --git a/src/image.ts b/src/image.ts index 49b2dc01..cc73fb31 100644 --- a/src/image.ts +++ b/src/image.ts @@ -165,7 +165,7 @@ export interface QuantizeOptions { /** * How the rounding error of each pixel is handled, which makes gradients look smooth with few colors: * `'none'` (each pixel takes its nearest color), `'floyd-steinberg'` (the error goes to the next pixels, 7/16, 3/16, - * 5/16 and 1/16), `'atkinson'` (the error goes to six neighbours, 1/8 each: lighter, more contrast), `'ordered'` (a + * 5/16 and 1/16), `'atkinson'` (the error goes to six neighbors, 1/8 each: lighter, more contrast), `'ordered'` (a * regular 4x4 pattern of thresholds, no error spreading). Rows alternate direction for the error diffusions. * Default: `'none'` */ @@ -1862,7 +1862,7 @@ export class Image { /** * Mixes the colors of every pixel with a color matrix, like SVG's `feColorMatrix`: the new red, green, blue and alpha - * are each a sum of the old red, green, blue and alpha times a factor, plus an offset. Greyscale, sepia, tints, + * are each a sum of the old red, green, blue and alpha times a factor, plus an offset. Grayscale, sepia, tints, * swapped channels, color blindness simulation or a mix of those in one pass. * * The values are 20 numbers, four rows of `[red, green, blue, alpha, offset]` for the new red, green, blue and alpha, @@ -2156,8 +2156,8 @@ export class Image { } /** - * Scales the color channels' distance from mid grey, e.g. 1.5 increases the contrast, 0.5 reduces it - * @param factor The factor (>= 0, 1 keeps the image, 0 makes it grey) + * Scales the color channels' distance from mid gray, e.g. 1.5 increases the contrast, 0.5 reduces it + * @param factor The factor (>= 0, 1 keeps the image, 0 makes it gray) */ contrast(factor: number): this { if (this.__gain_map__) return this.__recolor__(image => image.contrast(factor), false, v => (v - 0.5) * factor + 0.5); @@ -2180,10 +2180,10 @@ export class Image { } /** - * Converts the image to greyscale by its luma (ITU-R 601, like Pillow), keeping the alpha + * Converts the image to grayscale by its luma (ITU-R 601, like Pillow), keeping the alpha */ - greyscale(): this { - if (this.__gain_map__) return this.__recolor__(image => image.greyscale(), true); + grayscale(): this { + if (this.__gain_map__) return this.__recolor__(image => image.grayscale(), true); const bitmap = this.bitmap; for (let i = 0; i < bitmap.length; i += 4) bitmap[i] = bitmap[i + 1] = bitmap[i + 2] = bitmap[i] * 19595 + bitmap[i + 1] * 38470 + bitmap[i + 2] * 7471 + 0x8000 >> 16; @@ -2578,7 +2578,7 @@ 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 + * grayscale or RGB, alpha only if needed). Uses the runtime's native zlib when available * @param options See {@link PNGEncodeOptions} * @return The encoded data */ @@ -2703,7 +2703,7 @@ export class Image { /** * Encodes the image as JPEG XL: losslessly (the default) like libjxl's `cjxl -e 3` (a palette of up to 1024 colors, * else palettes of channels using few of their values; YCoCg; the weighted predictor in contexts of its error; - * greyscale if the image is), at effort 0 the codestream byte for byte like `cjxl -e 1` on CPUs with AVX2 or NEON + * grayscale if the image is), at effort 0 the codestream byte for byte like `cjxl -e 1` on CPUs with AVX2 or NEON * (its fast lossless mode: the gradient predictor, a palette if the image has at most 511 colors besides black, * else YCoCg), or lossy with a quality (like `cjxl -q`: XYB, varblocks of libjxl's search, chroma from luma, the * edge preserving filter). Alpha (if the image has transparency) is lossless either way. The codestream is in the @@ -2723,7 +2723,7 @@ export class Image { } /** - * Encodes the image as the smallest fitting Netpbm format: PBM for black and white images, PGM for greyscale ones, + * Encodes the image as the smallest fitting Netpbm format: PBM for black and white images, PGM for grayscale ones, * PPM for others, PAM if the image has transparency */ async encodePNM(): Promise> { diff --git a/tests/effects.js b/tests/effects.js index d4052510..92627cc3 100644 --- a/tests/effects.js +++ b/tests/effects.js @@ -9,7 +9,7 @@ const samePixels = require('./util/pixels'); const font = await fs.readFile('./tests/fonts/opensans bold.ttf'); const tiles = [ - tile().blur(3), tile().sharpen({amount: 2, radius: 2}), tile().greyscale(), tile().sepia(), + tile().blur(3), tile().sharpen({amount: 2, radius: 2}), tile().grayscale(), tile().sepia(), tile().brightness(1.3), tile().contrast(1.6), tile().gamma(2), tile().composite(gradient, {opacity: 0.4}), ...['multiply', 'screen', 'overlay', 'darken', 'lighten', 'color-dodge', 'color-burn', 'hard-light', 'soft-light', 'difference', 'exclusion', 'add'] diff --git a/tests/gainmap.js b/tests/gainmap.js index f442983c..958ed0c5 100644 --- a/tests/gainmap.js +++ b/tests/gainmap.js @@ -166,7 +166,7 @@ const meanValue = (image, left, top, right, bottom) => { { const image = single.clone().brightness(1).contrast(1); if (!same(image.gainMap.image, single.gainMap.image)) panic('brightness(1): the map changed'); - const grey = single.clone().greyscale(); + const grey = single.clone().grayscale(); if (!same(grey.gainMap.image, single.gainMap.image)) panic('greyscale: a single channel map changed'); } diff --git a/tests/regressions.js b/tests/regressions.js index 71504f4e..64173c48 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -764,7 +764,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); assert(rgba(px(0x804020ff).brightness(2), 0, 0).join() === '255,128,64,255', 'brightness(2)'); assert(rgba(px(0x804020ff).contrast(0), 0, 0).join() === '128,128,128,255', 'contrast(0) should be grey'); assert(rgba(px(0x40404080).gamma(2), 0, 0).join() === '128,128,128,128', 'gamma(2) should brighten and keep alpha, got ' + rgba(px(0x40404080).gamma(2), 0, 0)); - assert(rgba(px(0xff0000ff).greyscale(), 0, 0).join() === '76,76,76,255', 'greyscale should use the 601 luma'); + assert(rgba(px(0xff0000ff).grayscale(), 0, 0).join() === '76,76,76,255', 'greyscale should use the 601 luma'); assert(rgba(px(0xffffff80).sepia(), 0, 0).join() === '255,255,239,128', 'sepia, got ' + rgba(px(0xffffff80).sepia(), 0, 0)); let threw = 0; From 2b58720e015e221eb2bd0afbb0305fc135c84f6d Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:33:06 +0000 Subject: [PATCH 038/188] share the encoder option types --- src/image.ts | 71 +++++++++++++++++++++++----------------------------- 1 file changed, 31 insertions(+), 40 deletions(-) diff --git a/src/image.ts b/src/image.ts index cc73fb31..1c2dd6f9 100644 --- a/src/image.ts +++ b/src/image.ts @@ -271,14 +271,34 @@ export interface RadialGradientOptions extends GradientOptions { radius?: number; } +/** The options every encoder that writes metadata has */ +export interface EncodeOptions { + /** Metadata to include, see {@link Metadata} */ + metadata?: Metadata; +} + +/** + * How the encoders that can carry a gain map (JPEG, AVIF, JPEG XL) write {@link Image.gainMap}, see their `gainMap` + * option + */ +export interface GainMapEmbedOptions { + /** + * The map's quality (1-100, like the format's `quality`; for JPEG XL 100 is lossless). Default: `90` + */ + quality?: number; + /** The map's size as a factor of the image's (0-1). Default: the map's own size */ + scale?: number; +} + /** Options of {@link Image.encodePNG} */ -export interface PNGEncodeOptions { +export interface PNGEncodeOptions extends EncodeOptions { /** The compression level (0-9), higher is smaller but slower. Default: `4` */ compression?: number; - /** Metadata to include, see {@link Metadata} */ - metadata?: Metadata; } +/** Options of {@link Image.encodeTGA} */ +export type TGAEncodeOptions = EncodeOptions; + /** Options of {@link Image.encodeJPEG} */ export interface JPEGEncodeOptions extends EncodeOptions { /** The JPEG quality (1-100). Default: `90` */ @@ -288,22 +308,15 @@ export interface JPEGEncodeOptions extends EncodeOptions { * cameras). Default: `'4:2:0'` up to quality 90, `'4:4:4'` above (like libjpeg-turbo's `cjpeg`) */ subsampling?: '4:4:4' | '4:2:0'; - /** Metadata to include, see {@link Metadata} */ - metadata?: Metadata; /** * Whether to write the image's {@link Image.gainMap} (an Ultra HDR JPEG, brighter on HDR displays), or how. * Default: `true` (written if there is one) */ - gainMap?: boolean | { - /** The map's JPEG quality (1-100). Default: `90` */ - quality?: number, - /** The map's size as a factor of the image's (0-1). Default: the map's own size */ - scale?: number - }; + gainMap?: boolean | GainMapEmbedOptions; } /** Options of {@link Image.encodeWebP} */ -export interface WebPEncodeOptions { +export interface WebPEncodeOptions extends EncodeOptions { /** * Lossy compression (0-100, like libwebp's quality: 75 is its default): the image is encoded like cwebp encodes it * (with `-m` set to the effort), much smaller than lossless. Without it the image is encoded losslessly @@ -338,14 +351,6 @@ export interface WebPEncodeOptions { * most 8 */ threads?: number; - /** Metadata to include, see {@link Metadata} */ - metadata?: Metadata; -} - -/** Options of {@link Image.encodeTGA} */ -export interface EncodeOptions { - /** Metadata to include, see {@link Metadata} */ - metadata?: Metadata; } /** Options of {@link Image.roundCorners} */ @@ -445,7 +450,7 @@ export interface DecodeOptions { } /** Options of {@link Image.encodeJXL} */ -export interface JXLEncodeOptions { +export interface JXLEncodeOptions extends EncodeOptions { /** * Lossy compression (0-100, like cjxl's `-q`: 90 is visually lossless, 100 lossless): higher is better and larger. * Without it the image is encoded losslessly @@ -468,22 +473,15 @@ export interface JXLEncodeOptions { * Default: the processors available, at most 8 */ threads?: number; - /** Metadata to include, see {@link Metadata} */ - metadata?: Metadata; /** * Whether to write the image's {@link Image.gainMap} (brighter on HDR displays: a jhgm box, like libjxl's), or how. * Default: `true` (written if there is one) */ - gainMap?: boolean | { - /** The map's quality (1-100 like `quality`, 100 lossless). Default: `90` */ - quality?: number, - /** The map's size as a factor of the image's (0-1). Default: the map's own size */ - scale?: number - }; + gainMap?: boolean | GainMapEmbedOptions; } /** Options of {@link Image.encodeAVIF} */ -export interface AVIFEncodeOptions { +export interface AVIFEncodeOptions extends EncodeOptions { /** 0-100, like avifenc's: higher is better and larger, 100 is lossless. Default: `60` */ quality?: number; /** The quality of the alpha channel (if the image has transparency), like avifenc's. Default: `100` (lossless) */ @@ -499,18 +497,11 @@ export interface AVIFEncodeOptions { * them. Default: the processors available, at most 8 */ threads?: number; - /** Metadata to include, see {@link Metadata} */ - metadata?: Metadata; /** * Whether to write the image's {@link Image.gainMap} (brighter on HDR displays: a tone mapped item, like libavif * writes), or how. Default: `true` (written if there is one) */ - gainMap?: boolean | { - /** The map's quality (1-100). Default: `90` */ - quality?: number, - /** The map's size as a factor of the image's (0-1). Default: the map's own size */ - scale?: number - }; + gainMap?: boolean | GainMapEmbedOptions; } /** Text style options of {@link Image.renderText} */ @@ -2608,7 +2599,7 @@ export class Image { if (!gainMap || gainMapOption === false) return jpeg.encode(this.__bytes__(), this.width, this.height, finite(quality, 'quality'), base); // the gain map's JPEG first: the primary's XMP holds its length - const { quality: mapQuality, scale } = typeof gainMapOption === 'object' && gainMapOption !== null ? gainMapOption : {} as { quality?: number, scale?: number }; + const { quality: mapQuality, scale } = typeof gainMapOption === 'object' && gainMapOption !== null ? gainMapOption : {} as GainMapEmbedOptions; let width: number | undefined, height: number | undefined; if (scale !== undefined) { const s = finite(scale, 'gain map scale'); @@ -2641,7 +2632,7 @@ export class Image { * Encodes the image as run length encoded TGA (32 bit, or 24 bit if the image is opaque) * @param options See {@link EncodeOptions} */ - async encodeTGA(options: EncodeOptions = {}): Promise { + async encodeTGA(options: TGAEncodeOptions = {}): Promise> { checkOptions(options, 'image.encodeTGA({ metadata })'); const { metadata = {} } = options; const { author, comment, creationTime: time, software } = resolveMetadata(metadata); From 6d5469086959023c096267de4d534e5298ec9e26 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:33:06 +0000 Subject: [PATCH 039/188] use one DecodeOptions for every decode function --- src/animation.ts | 44 ++++++++++++-------------------------------- src/image.ts | 14 +++++++++----- src/index.ts | 18 +++++++++--------- 3 files changed, 30 insertions(+), 46 deletions(-) diff --git a/src/animation.ts b/src/animation.ts index 3067ef83..78d0f7f0 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -6,8 +6,7 @@ import * as jxl from './codecs/jxl.ts'; import { availableThreads } from './util/threads.ts'; import { BinaryData, view } from './util/mem.ts'; import { GainMap } from './gainmap.ts'; -import type { ToneMapping } from './codecs/hdr.ts'; -import { Image, ResizeOptions, ResizeToOptions, resizeArguments } from './image.ts'; +import { Image, DecodeOptions, ResizeOptions, ResizeToOptions, resizeArguments } from './image.ts'; import { Frame, FrameDisposalModes } from './frame.ts'; import { ImageType } from './image-type.ts'; import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts'; @@ -36,34 +35,15 @@ export interface AnimationOptions { loopCount?: number; } -/** Options of {@link Animation.decode} */ -export interface AnimationDecodeOptions { +/** Options of {@link Animation}'s constructor: the canvas size and the loop count */ +export interface AnimationCanvasOptions extends AnimationOptions { /** - * The most pixels to decode, of all frames together (every frame is a full canvas): larger animations throw a - * RangeError before anything that size is allocated, so small files declaring huge animations (decompression - * bombs) can't exhaust the memory. Default: `268402689` (16383², like sharp), `Infinity` for no limit + * The canvas width in pixels: frames (or the parts of them) outside the canvas aren't encoded. Default: the + * frames' right edge (the largest `xOffset + width`), following the frames until a canvas size is set */ - maxPixels?: number; - /** Whether to convert the colors of frames with an embedded ICC profile (APNGs, WebPs) to sRGB, like `applyColorProfile` of {@link Image.decode}. Default: `true` */ - applyColorProfile?: boolean; - /** At most this many worker threads decode in parallel, like `threads` of {@link Image.decode}. Default: the processors available, at most 8 */ - threads?: number; - /** - * Whether to rotate/mirror the frames as the orientation tag (Exif, JPEG XL's header, PNG's eXIf chunk or XMP) - * says, like `applyOrientation` of {@link Image.decode}: APNGs, animated WebPs and JPEG XLs. Default: `true` - */ - applyOrientation?: boolean; - /** - * How HDR (PQ or HLG) JPEG XL animations, whose light can be brighter than (SDR) white, become 8 bit sRGB, like - * `toneMapping` of {@link Image.decode}: `'reinhard'` compresses the brightest parts, `'clamp'` clamps everything - * brighter than white. Default: `'reinhard'` - */ - toneMapping?: ToneMapping; - /** - * Whether to read the gain map (jhgm box) of JPEG XL animations into {@link Image.gainMap} of every frame, like - * `gainMap` of {@link Image.decode}. Default: `true` - */ - gainMap?: boolean; + width?: number; + /** The canvas height in pixels, like `width`. Default: the frames' bottom edge (the largest `yOffset + height`) */ + height?: number; } /** The options all animation encoders have */ @@ -416,10 +396,10 @@ export class Animation { /** * Decodes an animated GIF, APNG, WebP or JPEG XL into fully composited frames (still images become a single frame) * @param input The binary data to decode - * @param options See {@link AnimationDecodeOptions} + * @param options See {@link DecodeOptions} */ - static decode(input: BinaryData, options?: AnimationDecodeOptions): Promise; - static async decode(input: BinaryData, options: AnimationDecodeOptions = {}): Promise { + static decode(input: BinaryData, options?: DecodeOptions): Promise; + static async decode(input: BinaryData, options: DecodeOptions = {}): Promise { // (checked outside of decoding(), which reports RangeErrors as damaged data) checkOptions(options, 'Animation.decode(data, { maxPixels: 1e8 })'); const { maxPixels = MAX_PIXELS, threads = 1 } = options; @@ -434,7 +414,7 @@ export class Animation { * @internal * @ignore */ - private static async __decode__(input: BinaryData, options: AnimationDecodeOptions): Promise { + private static async __decode__(input: BinaryData, options: DecodeOptions): Promise { const { maxPixels = MAX_PIXELS, applyColorProfile = true, threads = Math.max(1, Math.min(availableThreads(), 8)), applyOrientation = true, toneMapping = 'reinhard', gainMap = true } = options; const orient = (frames: Frame[], orientation: number): Frame[] => { if (applyOrientation && orientation > 1) for (const frame of frames) frame.__orient__(orientation); diff --git a/src/image.ts b/src/image.ts index 1c2dd6f9..1e4c7cb3 100644 --- a/src/image.ts +++ b/src/image.ts @@ -410,9 +410,12 @@ export interface RenderTextOptions extends TextStyle { layout?: TextLayoutOptions | TextLayout; } -/** Options of {@link Image.decode} */ +/** Options of {@link Image.decode}, {@link Animation.decode} and {@link decode} */ export interface DecodeOptions { - /** Whether to rotate/mirror JPEGs, TIFFs, PNGs and WebPs as their orientation tag (Exif or XMP) says. Default: `true` */ + /** + * Whether to rotate/mirror the image (or every frame) as its orientation tag says: Exif in JPEGs, TIFFs, PNGs + * (also eXIf and XMP, and in APNGs) and WebPs (also animated ones), and JPEG XL's header. Default: `true` + */ applyOrientation?: boolean; /** * Whether to convert the colors of images with an embedded ICC profile (PNG, JPEG, WebP, AVIF, JPEG XL, TIFF), and @@ -424,8 +427,9 @@ export interface DecodeOptions { applyColorProfile?: boolean; /** * The most pixels to decode: larger images throw a RangeError before anything that size is allocated, so small - * files declaring huge images (decompression bombs) can't exhaust the memory. Default: `268402689` (16383², like - * sharp), `Infinity` for no limit + * files declaring huge images (decompression bombs) can't exhaust the memory. For {@link Animation.decode}, all + * frames together (every frame is a full canvas). Default: `268402689` (16383², like sharp), `Infinity` for no + * limit */ maxPixels?: number; /** @@ -437,7 +441,7 @@ export interface DecodeOptions { toneMapping?: ToneMapping; /** * Whether to read the gain maps of JPEGs (Ultra HDR, JPEG-R, ISO 21496-1, Apple's), AVIFs (tone mapped items) and - * JPEG XLs (jhgm boxes) into {@link Image.gainMap}. Default: `true` + * JPEG XLs (jhgm boxes, also of every frame of an animation) into {@link Image.gainMap}. Default: `true` */ gainMap?: boolean; /** diff --git a/src/index.ts b/src/index.ts index 7edbcc05..c2b712c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { BinaryData } from './util/mem.ts'; import { Image, DecodeOptions } from './image.ts'; import { Frame } from './frame.ts'; -import { Animation, AnimationDecodeOptions } from './animation.ts'; +import { Animation } from './animation.ts'; import { checkOptions } from './util/options.ts'; import * as png from './codecs/png.ts'; import * as webp from './codecs/webp.ts'; @@ -16,7 +16,7 @@ export type { FrameDisposalModeName, FrameOptions } from './frame.ts'; export { GainMap } from './gainmap.ts'; export type { GainMapOptions, GainMapEncodeOptions } from './gainmap.ts'; export { Animation } from './animation.ts'; -export type { AnimationOptions, AnimationDecodeOptions, AnimationEncodeOptions, WebPAnimationEncodeOptions, GIFEncodeOptions, APNGEncodeOptions } from './animation.ts'; +export type { AnimationOptions, AnimationCanvasOptions, AnimationEncodeOptions, WebPAnimationEncodeOptions, GIFEncodeOptions, APNGEncodeOptions } from './animation.ts'; export { AnimationEncoder } from './animation-encoder.ts'; export { TextLayout } from './text-layout.ts'; export type { TextLayoutOptions, WrapStyle, HorizontalAlign, VerticalAlign } from './text-layout.ts'; @@ -32,18 +32,18 @@ export type { Metadata } from './metadata.ts'; * (also a GIF, APNG, WebP or JPEG XL with one frame) becomes an Image of that frame, without its offsets and duration. * Static formats (PNG, JPEG, AVIF, ...) are always Images, AVIF image sequences decode to their first frame, as * {@link Image.decode} does. The options are passed on to {@link Animation.decode} or {@link Image.decode}. - * @param data The image data - * @param options See {@link AnimationDecodeOptions} and {@link DecodeOptions} + * @param input The image data + * @param options See {@link DecodeOptions} */ -export function decode(data: BinaryData, options?: AnimationDecodeOptions & DecodeOptions): Promise; -export async function decode(data: BinaryData, options: AnimationDecodeOptions & DecodeOptions = {}): Promise { +export function decode(input: BinaryData, options?: DecodeOptions): Promise; +export async function decode(input: BinaryData, options: DecodeOptions = {}): Promise { checkOptions(options, 'decode(data, { maxPixels: 1e8 })'); - const bytes = view(data); + const bytes = view(input); const type = ImageType.getType(bytes); const animated = type === 'gif' || type === 'jxl' || (type === 'webp' && webp.isAnimated(bytes)) || (type === 'png' && png.isAnimated(bytes)); - if (!animated) return Image.decode(data, options); + if (!animated) return Image.decode(input, options); - const animation = await Animation.decode(data, options); + const animation = await Animation.decode(input, options); if (animation.length !== 1) return animation; // the only frame, as an image: its pixels and gain map const frame: Frame = animation.frames[0]; From 0337b83f00cd3b9aef9e05feb61b744644939baf Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:33:06 +0000 Subject: [PATCH 040/188] let Colors.gradient take an array of colors --- src/colors.ts | 12 ++++++++++-- src/image.ts | 11 +++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/colors.ts b/src/colors.ts index d6b29fca..c70b1535 100644 --- a/src/colors.ts +++ b/src/colors.ts @@ -192,10 +192,18 @@ export class Colors { /** * Creates a multi-point gradient generator - * @param colors The gradient points to use (e.g. `{0: 0xff0000ff, 1: 0x00ff00ff}`) + * @param stops The colors along the gradient: their positions (0..1) and colors (`{ 0: 0xff0000ff, 1: 0x00ff00ff }`), + * or just the colors, evenly spaced (`[0xff0000ff, 0x00ff00ff]`), like {@link GradientOptions.stops} * @return The gradient generator. The function argument is the position in the gradient (0..1). */ - static gradient(colors: Record): (position: number) => Color { + static gradient(stops: Record | Color[]): (position: number) => Color { + let colors: Record; + if (Array.isArray(stops)) { + if (!stops.length) throw new RangeError('Invalid gradient point count'); + // (one color is a gradient of two equal ones) + const list = stops.length === 1 ? [stops[0], stops[0]] : stops; + colors = Object.fromEntries(list.map((color, i) => [i / (list.length - 1), color])); + } else colors = stops; const entries = Object.entries(colors).sort((a, b) => Number(a[0]) - Number(b[0])); const positions = entries.map(e => finite(parseFloat(e[0]), 'gradient position')); const values = entries.map(e => checkColor(e[1])); diff --git a/src/image.ts b/src/image.ts index 1e4c7cb3..a16dfb06 100644 --- a/src/image.ts +++ b/src/image.ts @@ -244,9 +244,8 @@ export interface OutlineOptions { /** The colors of a gradient, see {@link Image.linearGradient} */ export interface GradientOptions { /** - * The colors along the gradient: their positions (0..1) and colors, like {@link Colors.gradient} takes them - * (`{ 0: 0xff0000ff, 0.5: 0xffff00ff, 1: 0x00ff00ff }`), or just the colors, evenly spaced - * (`[0xff0000ff, 0x00ff00ff]`) + * The colors along the gradient: their positions (0..1) and colors (`{ 0: 0xff0000ff, 0.5: 0xffff00ff, 1: 0x00ff00ff }`), + * or just the colors, evenly spaced (`[0xff0000ff, 0x00ff00ff]`), like {@link Colors.gradient} takes them */ stops: Record | Color[]; } @@ -3086,11 +3085,7 @@ const BLEND_MODES: Record numbe // the color at every position of a gradient, from its stops function gradientFunction(stops: Record | Color[]): (position: number) => Color { - if (!Array.isArray(stops)) return Colors.gradient(stops); - if (!stops.length) throw new RangeError('Invalid gradient point count'); - // (one color is a gradient of two equal ones) - const colors = stops.length === 1 ? [stops[0], stops[0]] : stops; - return Colors.gradient(Object.fromEntries(colors.map((color, i) => [i / (colors.length - 1), color]))); + return Colors.gradient(stops); } /** From f47eff285b6222950a8e2d135854c099ed74c7b7 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:33:06 +0000 Subject: [PATCH 041/188] take and return hues in degrees --- src/colors.ts | 13 +++++++------ tests/crop.js | 4 ++-- tests/fill.js | 2 +- tests/gif.js | 2 +- tests/image.js | 2 +- tests/rotate.js | 2 +- 6 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/colors.ts b/src/colors.ts index c70b1535..8eec10b9 100644 --- a/src/colors.ts +++ b/src/colors.ts @@ -117,14 +117,14 @@ export class Colors { /** * Converts HSLA colors to RGBA colors - * @param h hue (0..1) + * @param h hue in degrees (0..360, like CSS' `hsl()`: 0 red, 120 green, 240 blue; other values wrap around) * @param s saturation (0..1) * @param l lightness (0..1) * @param a opacity (0..1) * @returns color */ static fromHSLA(h: number, s: number, l: number, a: number): Color { - h = wrapHue(finite(h, 'hue')); + h = wrapHue(finite(h, 'hue') / 360); s = finite(s, 'saturation'); l = finite(l, 'lightness'); a = finite(a, 'alpha'); @@ -149,8 +149,8 @@ export class Colors { } /** - * Converts HSL colors to RGBA colors (assuming an opacity of 255) - * @param h hue (0..1) + * Converts HSL colors to RGBA colors (fully opaque) + * @param h hue in degrees (0..360, like CSS' `hsl()`; other values wrap around) * @param s saturation (0..1) * @param l lightness (0..1) * @returns color @@ -182,11 +182,12 @@ export class Colors { /** * Converts a color to an array of HSLA values * @param color The color value to convert - * @returns The HSLA values ([H, S, L, A]), H and S and L and A in 0..1 + * @returns The HSLA values ([H, S, L, A]): H in degrees (0..360, like CSS' `hsl()`), S, L and A in 0..1 */ static toHSLA(color: Color): [h: number, s: number, l: number, a: number] { const [r, g, b, a] = Colors.toRGBA(color); - return rgbaToHSLA(r, g, b, a); + const [h, s, l, alpha] = rgbaToHSLA(r, g, b, a); + return [h * 360, s, l, alpha]; } diff --git a/tests/crop.js b/tests/crop.js index 403e7168..08689d9a 100644 --- a/tests/crop.js +++ b/tests/crop.js @@ -4,7 +4,7 @@ const samePixels = require('./util/pixels'); (async () => { const image = new Image(512, 512); - image.fill((x, y) => Colors.fromHSL((x + y) / (image.width + image.height), 1, .5)); + image.fill((x, y) => Colors.fromHSL((x + y) / (image.width + image.height) * 360, 1, .5)); image.crop(128, 128, 128, 128); const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); @@ -20,7 +20,7 @@ const samePixels = require('./util/pixels'); console.error(message); process.exit(1); }; - const source = new Image(512, 512).fill((x, y) => Colors.fromHSL((x + y) / 1024, 1, .5)); + const source = new Image(512, 512).fill((x, y) => Colors.fromHSL((x + y) / 1024 * 360, 1, .5)); const before = Buffer.from(source.bitmap); const region = source.region(128, 128, 128, 128); if (!await samePixels(target, await region.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}))) panic('region: wrong pixels'); diff --git a/tests/fill.js b/tests/fill.js index 263a23b5..c808f7ae 100644 --- a/tests/fill.js +++ b/tests/fill.js @@ -23,7 +23,7 @@ const panic = message => { { const image = new Image(512, 512); - image.fill((x, y) => Colors.fromHSL((x + y) / (image.width + image.height), 1, .5)); + image.fill((x, y) => Colors.fromHSL((x + y) / (image.width + image.height) * 360, 1, .5)); const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); diff --git a/tests/gif.js b/tests/gif.js index 268d9928..ab868c19 100644 --- a/tests/gif.js +++ b/tests/gif.js @@ -11,7 +11,7 @@ const panic = message => { const frames = []; for (let i = 0; i < 30; i++) { const frame = new Frame(256, 256); - frame.fill(x => Colors.fromHSL(x / frame.width + i / 30, 1, 0.5)); + frame.fill(x => Colors.fromHSL((x / frame.width + i / 30) * 360, 1, 0.5)); frames.push(frame); } diff --git a/tests/image.js b/tests/image.js index ec021d96..d43b0349 100644 --- a/tests/image.js +++ b/tests/image.js @@ -4,7 +4,7 @@ const samePixels = require('./util/pixels'); module.exports = (async () => { const image = new Image(128, 128); - image.fill(x => Colors.fromHSL(x / image.width, 1, 0.5)); + image.fill(x => Colors.fromHSL(x / image.width * 360, 1, 0.5)); const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); diff --git a/tests/rotate.js b/tests/rotate.js index 9d342631..8002674c 100644 --- a/tests/rotate.js +++ b/tests/rotate.js @@ -75,7 +75,7 @@ const panic = msg => { { const image = new Image(512, 512); - image.fill((x) => Colors.fromHSL(x / image.width, 1, .5)); + image.fill((x) => Colors.fromHSL(x / image.width * 360, 1, .5)); if (!Buffer.from(image.bitmap).equals(Buffer.from(image.rotate(360).bitmap))) panic('rotate 360 failed'); } From 57cbf82c2270ffbeeccc4453f6dd1fdfa97d4a22 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:33:06 +0000 Subject: [PATCH 042/188] rename the parameters of setPixelAt, lightness and saturation --- src/image.ts | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/image.ts b/src/image.ts index a16dfb06..f31f4c60 100644 --- a/src/image.ts +++ b/src/image.ts @@ -830,13 +830,13 @@ export class Image { * Sets the pixel color for the specified position (0-based) * @param x The x coordinate * @param y The y coordinate - * @param pixelColor The new color (`0xRRGGBBAA`) + * @param color The new color (`0xRRGGBBAA`) */ - setPixelAt(x: number, y: number, pixelColor: Color): this { + setPixelAt(x: number, y: number, color: Color): this { x = ~~finite(x, 'x'); y = ~~finite(y, 'y'); this.__check_boundaries__(x, y); - this.__set_pixel__(x, y, checkColor(pixelColor)); + this.__set_pixel__(x, y, checkColor(color)); return this; } @@ -1478,30 +1478,30 @@ export class Image { /** * Scales the lightness of the image (HSL), keeping its hue and saturation - * @param value The factor to multiply the lightness with (>= 0, results are clamped) + * @param factor The factor to multiply the lightness with (>= 0, results are clamped) */ - lightness(value: number): this { + lightness(factor: number): this { checkFactorOnly('lightness', arguments.length); - if (this.__gain_map__) return this.__recolor__(image => image.lightness(value)); - value = finite(value, 'lightness value'); - if (value < 0) - throw new RangeError('Invalid lightness value'); + if (this.__gain_map__) return this.__recolor__(image => image.lightness(factor)); + factor = finite(factor, 'lightness factor'); + if (factor < 0) + throw new RangeError('Invalid lightness factor'); - return this.__map_hsla__(c => { c.l = value * c.l; }); + return this.__map_hsla__(c => { c.l = factor * c.l; }); } /** - * Scales the saturation of the image (HSL), keeping its hue and lightness. 0 makes it greyscale - * @param value The factor to multiply the saturation with (>= 0, results are clamped) + * Scales the saturation of the image (HSL), keeping its hue and lightness. 0 makes it grayscale + * @param factor The factor to multiply the saturation with (>= 0, results are clamped) */ - saturation(value: number): this { + saturation(factor: number): this { checkFactorOnly('saturation', arguments.length); - if (this.__gain_map__) return this.__recolor__(image => image.saturation(value), true); - value = finite(value, 'saturation value'); - if (value < 0) - throw new RangeError('Invalid saturation value'); + if (this.__gain_map__) return this.__recolor__(image => image.saturation(factor), true); + factor = finite(factor, 'saturation factor'); + if (factor < 0) + throw new RangeError('Invalid saturation factor'); - return this.__map_hsla__(c => { c.s = value * c.s; }); + return this.__map_hsla__(c => { c.s = factor * c.s; }); } /** From 5d9fe6bf105de4535360e3ada3a800f8c17d8a48 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:33:06 +0000 Subject: [PATCH 043/188] rename the resize option of rotate to expand --- src/image.ts | 15 +++++++++------ tests/options-only.js | 2 +- tests/regressions.js | 4 ++-- tests/rotate.js | 4 ++-- tests/transform.js | 2 +- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/image.ts b/src/image.ts index f31f4c60..7ef2b26c 100644 --- a/src/image.ts +++ b/src/image.ts @@ -560,8 +560,11 @@ export interface DominantColorOptions { /** Options of {@link Image.rotate} */ export interface RotateOptions { - /** Whether to resize the image so it fits all pixels or just ignore outlying pixels. Default: `true` */ - resize?: boolean; + /** + * Whether to make the image larger so the rotated corners aren't cut off. Without it, the image keeps its size and + * the parts outside are lost. Default: `true` + */ + expand?: boolean; /** * Whether to sample with 8-bit weights on 8-bit premultiplied pixels (like {@link ResizeOptions.fast}), about twice * as fast: opaque pixels stay within a step of the precise result, what shows of translucent ones (also the @@ -2304,8 +2307,8 @@ export class Image { */ rotate(angle: number, options?: RotateOptions): this; rotate(angle: number, options: RotateOptions = {}): this { - checkOptions(options, 'rotate(angle, { resize: false })'); - const { resize = true, fast = true } = options; + checkOptions(options, 'rotate(angle, { expand: false })'); + const { expand = true, fast = true } = options; angle = ((finite(angle, 'angle') % 360) + 360) % 360; if (angle === 0) return this; @@ -2340,8 +2343,8 @@ export class Image { const rad = angle * Math.PI / 180; const sin = Math.sin(rad), cos = Math.cos(rad); - const outWidth = resize ? Math.round(Math.abs(width * cos) + Math.abs(height * sin)) : width; - const outHeight = resize ? Math.round(Math.abs(width * sin) + Math.abs(height * cos)) : height; + const outWidth = expand ? Math.round(Math.abs(width * cos) + Math.abs(height * sin)) : width; + const outHeight = expand ? Math.round(Math.abs(width * sin) + Math.abs(height * cos)) : height; const out = new Image(outWidth, outHeight); rotateGainMap(outWidth, outHeight); if (fast && rotateBilinear(this.bitmap, width, height, out.bitmap, outWidth, outHeight, sin, cos)) return this.__apply__(out); diff --git a/tests/options-only.js b/tests/options-only.js index e059ae84..84cd377c 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -76,7 +76,7 @@ const sameImage = (image, reference, label) => { if (solid.clone().composite(patch, {x: 1, y: 1}).getPixelAt(1, 1) !== 0xff0000ff) panic('composite(patch, { x, y }) should place the patch'); if (solid.clone().sharpen({amount: 0}).getPixelAt(2, 2) !== 0x000000ff) panic('sharpen({ amount: 0 }) should not change the pixels'); if (solid.clone().roundCorners({radius: 0}).getPixelAt(0, 0) !== 0xffffffff) panic('roundCorners({ radius: 0 }) should not change the pixels'); - if (solid.clone().rotate(90, {resize: false}).width !== 6) panic('rotate(90, { resize: false })'); + if (solid.clone().rotate(90, {expand: false}).width !== 6) panic('rotate(90, { expand: false })'); // the encoders take their values in the options object only; the metadata goes in { metadata } const makeAnimation = () => new Animation([new Frame(4, 4)]); diff --git a/tests/regressions.js b/tests/regressions.js index 64173c48..2ba05637 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -32,7 +32,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // free rotation keeps the edges { - const image = new Image(4, 4).fill(0xff0000ff).rotate(0.001, {resize: false}); + const image = new Image(4, 4).fill(0xff0000ff).rotate(0.001, {expand: false}); for (const [x, y, color] of image.pixels()) assert((color & 0xff) > 250, `rotate: edge pixel ${x},${y} lost`); } @@ -1041,7 +1041,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); crop: v => image().crop(v, 0, 2, 2), trim: v => image().trim({color: v}), drawBox: v => image().drawBox(0, 0, v, 2, 0), drawCircle: v => image().drawCircle(2, 2, v, 0), cropCircle: v => image().cropCircle({ feathering: v }), opacity: v => image().opacity(v), red: v => image().red(v), lightness: v => image().lightness(v), composite: v => image().composite(image(), {x: v, y: 0}), hueShift: v => image().hueShift(v), brightness: v => image().brightness(v), gamma: v => image().gamma(v), - colorToAlpha: v => image().colorToAlpha({color: v}), rotate: v => image().rotate(v, {resize: false}), gradient: v => Colors.gradient({0: v, 1: 0}), + colorToAlpha: v => image().colorToAlpha({color: v}), rotate: v => image().rotate(v, {expand: false}), gradient: v => Colors.gradient({0: v, 1: 0}), roundCorners: v => image().roundCorners({radius: v}), fisheye: v => image().fisheye({strength: v}), encodePNG: v => image().encodePNG({compression: v}), encodeJPEG: v => image().encodeJPEG({quality: v}), frame: v => new Frame(4, 4, {duration: v}), animation: v => new Animation([new Frame(4, 4)], {loopCount: v}), }; diff --git a/tests/rotate.js b/tests/rotate.js index 8002674c..6f0c2c57 100644 --- a/tests/rotate.js +++ b/tests/rotate.js @@ -23,7 +23,7 @@ const panic = msg => { { const binary = await fs.readFile('./tests/targets/image.png'); const image = await Image.decode(binary); - image.rotate(45, {resize: false}); + image.rotate(45, {expand: false}); const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); @@ -88,7 +88,7 @@ const panic = msg => { for (let i = 0; i < transparent.bitmap.length; i += 4) transparent.bitmap[i + 3] = (i >> 2) % transparent.width * 255 / (transparent.width - 1); for (const source of [photo, transparent]) for (const [angle, resize] of [[30, true], [-17.5, true], [123, false], [0.3, true]]) { - const a = source.clone().rotate(angle, {resize, fast: false}).bitmap, b = source.clone().rotate(angle, {resize}).bitmap; + const a = source.clone().rotate(angle, {expand: resize, fast: false}).bitmap, b = source.clone().rotate(angle, {expand: resize}).bitmap; let opaque = 0, shown = 0; for (let i = 0; i < a.length; i++) { if (a[i] === b[i]) continue; diff --git a/tests/transform.js b/tests/transform.js index f9f0b08f..9f3ff0c9 100644 --- a/tests/transform.js +++ b/tests/transform.js @@ -37,7 +37,7 @@ const photo = (width, height) => new Image(width, height).fill((x, y) => Colors. // any angle: close to rotate() (counterclockwise 30 degrees), on a canvas that keeps its size const angle = 30 * Math.PI / 180, big = photo(40, 30); const ours = big.clone().transform([Math.cos(angle), -Math.sin(angle), Math.sin(angle), Math.cos(angle), 0, 0], {expand: false}); - const theirs = big.clone().rotate(30, {resize: false}); + const theirs = big.clone().rotate(30, {expand: false}); let total = 0; for (let i = 0; i < ours.bitmap.length; i++) total += Math.abs(ours.bitmap[i] - theirs.bitmap[i]); check(ours.width === 40 && ours.height === 30 && total / ours.bitmap.length < 3, `transform: a rotation by any angle is rotate()'s (mean difference ${total / ours.bitmap.length})`); From 71479bc0ab457103ccba4ed1b03bac35e5b80685 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:33:06 +0000 Subject: [PATCH 044/188] let renderSVG take a width, height or scale --- src/image.ts | 50 ++++++++++++++++-------------------------- src/index.ts | 4 ++-- tests/option-checks.js | 2 +- tests/options-only.js | 6 ++--- tests/regressions.js | 18 ++++++++------- tests/svg-images.js | 4 ++-- tests/svg.js | 6 ++--- tests/util/resvg.js | 6 ++--- 8 files changed, 43 insertions(+), 53 deletions(-) diff --git a/src/image.ts b/src/image.ts index 7ef2b26c..e325231b 100644 --- a/src/image.ts +++ b/src/image.ts @@ -50,9 +50,6 @@ export type ColorFunction = (x: number, y: number) => Color; /** Resampling filters for {@link Image.resize} and the other scaling functions, see {@link ResizeModes} */ export type ResizeMode = 'nearest-neighbor' | 'bilinear' | 'bicubic' | 'area'; -/** How {@link Image.renderSVG} scales, see {@link SVGScaleModes} */ -export type SVGScaleMode = 'scale' | 'width' | 'height'; - /** * The resampling filters of {@link Image.resize} and the other scaling functions: * `image.resize({ width: 256, mode: ResizeModes.BICUBIC })` @@ -81,20 +78,6 @@ export class ResizeModes { private constructor() {} } -/** How {@link Image.renderSVG} scales the SVG to the given size */ -export class SVGScaleModes { - /** The size is a factor of the SVG's own size */ - static readonly SCALE = 'scale'; - - /** The size is the width in pixels, the height follows the SVG's aspect ratio */ - static readonly WIDTH = 'width'; - - /** The size is the height in pixels, the width follows the SVG's aspect ratio */ - static readonly HEIGHT = 'height'; - - private constructor() {} -} - /** Blend modes of {@link Image.composite}, named like CSS' mix-blend-mode values */ export type BlendMode = 'normal' | 'multiply' | 'screen' | 'overlay' | 'darken' | 'lighten' | 'color-dodge' | 'color-burn' | 'hard-light' | 'soft-light' | 'difference' | 'exclusion' | 'add'; @@ -366,13 +349,18 @@ export interface FisheyeOptions { /** Options of {@link Image.renderSVG} */ export interface SVGOptions { - /** The scale, or the width or height in pixels, see `mode`. Default: `1` */ - size?: number; /** - * What `size` is: one of {@link SVGScaleModes.SCALE}, {@link SVGScaleModes.WIDTH}, {@link SVGScaleModes.HEIGHT}. - * Default: {@link SVGScaleModes.SCALE} + * The width in pixels, the height follows the SVG's aspect ratio. Give one of `width`, `height` and `scale`. + * Default: the SVG's own width */ - mode?: SVGScaleMode; + width?: number; + /** The height in pixels, the width follows the SVG's aspect ratio. Give one of `width`, `height` and `scale` */ + height?: number; + /** + * A factor of the SVG's own size (`2` renders it twice as large). Give one of `width`, `height` and `scale`. + * Default: `1` + */ + scale?: number; /** * Fonts for `` (TrueType, OpenType): each text gets the face its `font-family`, `font-weight`, `font-style` * and `font-stretch` match best among them, by the names, weights and styles in the fonts (like browsers match @@ -2873,16 +2861,16 @@ export class Image { */ static renderSVG(svg: string | BinaryData, options?: SVGOptions): Promise; static async renderSVG(svg: string | BinaryData, options: SVGOptions = {}): Promise { - checkOptions(options, 'renderSVG(svg, { size: 2, mode: \'width\' })'); - const { size = 1, mode = SVGScaleModes.SCALE, fonts = [], images: givenImages = {} } = options; - if (mode !== 'scale' && mode !== 'width' && mode !== 'height') - throw new Error('Invalid SVG scaling mode'); - - finite(size, 'SVG size'); - if (mode === SVGScaleModes.SCALE && size <= 0) + checkOptions(options, 'renderSVG(svg, { width: 512 })'); + const { width: givenWidth, height: givenHeight, scale, fonts = [], images: givenImages = {} } = options; + const sizes = [givenWidth, givenHeight, scale].filter(value => value !== undefined).length; + if (sizes > 1) throw new TypeError('renderSVG() takes one of width, height and scale'); + const mode = givenWidth !== undefined ? 'width' : givenHeight !== undefined ? 'height' : 'scale'; + const size = finite(givenWidth ?? givenHeight ?? scale ?? 1, `SVG ${mode}`); + if (mode === 'scale' && size <= 0) throw new RangeError('SVG scale must be > 0'); - if (mode !== SVGScaleModes.SCALE && size < 1) - throw new RangeError('SVG size must be >= 1'); + if (mode !== 'scale' && size < 1) + throw new RangeError(`SVG ${mode} must be >= 1`); // the images given by href, checked before anything is decoded if (typeof givenImages !== 'object' || givenImages === null || Array.isArray(givenImages) || givenImages instanceof Map) diff --git a/src/index.ts b/src/index.ts index c2b712c2..ecf22763 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,9 +8,9 @@ import * as webp from './codecs/webp.ts'; import { view } from './util/mem.ts'; import { ImageType } from './image-type.ts'; -export { Image, ResizeModes, SVGScaleModes } from './image.ts'; +export { Image, ResizeModes } from './image.ts'; export { Colors } from './colors.ts'; -export type { Color, ColorFunction, ResizeMode, SVGScaleMode, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, SVGOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts'; +export type { Color, ColorFunction, ResizeMode, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, ContainOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, TGAEncodeOptions, GainMapEmbedOptions, SVGOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts'; export { Frame, FrameDisposalModes } from './frame.ts'; export type { FrameDisposalModeName, FrameOptions } from './frame.ts'; export { GainMap } from './gainmap.ts'; diff --git a/tests/option-checks.js b/tests/option-checks.js index 5d26d078..5ff3ae37 100644 --- a/tests/option-checks.js +++ b/tests/option-checks.js @@ -52,7 +52,7 @@ const rejects = async (name, fn, Type, pattern) => { await rejects('Animation loopCount 1.5', () => new Animation([new Frame(8, 8)], {loopCount: 1.5}), RangeError, /loop count/); await rejects('dominantColor threshold NaN', () => image.dominantColor({threshold: NaN}), RangeError, /threshold/); - await rejects('renderSVG size NaN', () => Image.renderSVG('', {size: NaN}), RangeError, /SVG size/); + await rejects('renderSVG scale NaN', () => Image.renderSVG('', {scale: NaN}), RangeError, /SVG scale/); await rejects('renderText size -1', () => Image.renderText('x', { font: new Uint8Array(0), size: -1 }), RangeError, /font size/); const png = await image.encodePNG(); for (const maxPixels of [NaN, -1, 0]) { diff --git a/tests/options-only.js b/tests/options-only.js index 84cd377c..364bb85a 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -107,9 +107,9 @@ const sameImage = (image, reference, label) => { const error = await rejects(call, label); if (!/renderSVG\(svg, \{/.test(error.message)) panic(`${label}: the message should show the options form, got ${error.message}`); } - if ((await Image.renderSVG(svgSource, {size: 2})).width !== 20) panic('renderSVG(svg, { size }) should scale'); - const byWidth = await Image.renderSVG(svgSource, {size: 30, mode: 'width'}); - if (byWidth.width !== 30 || byWidth.height !== 30) panic(`renderSVG(svg, { size, mode: 'width' }): ${byWidth.width}x${byWidth.height}`); + if ((await Image.renderSVG(svgSource, {scale: 2})).width !== 20) panic('renderSVG(svg, { scale }) should scale'); + const byWidth = await Image.renderSVG(svgSource, {width: 30}); + if (byWidth.width !== 30 || byWidth.height !== 30) panic(`renderSVG(svg, { width }): ${byWidth.width}x${byWidth.height}`); // frames and animations take their duration, offsets, disposal mode and loop count in the options object only const frame = new Frame(4, 4, {duration: 40, xOffset: 2, yOffset: 3, disposalMode: 'background'}); diff --git a/tests/regressions.js b/tests/regressions.js index 2ba05637..7e8bb8b4 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -383,13 +383,15 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // the constant classes hold the names of the modes (the deprecated Image/Frame constants were removed in 4.0) { assert(ResizeModes.NEAREST_NEIGHBOR === 'nearest-neighbor' && ResizeModes.BILINEAR === 'bilinear' && ResizeModes.BICUBIC === 'bicubic' && ResizeModes.AREA === 'area', 'ResizeModes values'); - assert(SVGScaleModes.SCALE === 'scale' && SVGScaleModes.WIDTH === 'width' && SVGScaleModes.HEIGHT === 'height', 'SVGScaleModes values'); assert(FrameDisposalModes.ANY === 'any' && FrameDisposalModes.KEEP === 'keep' && FrameDisposalModes.PREVIOUS === 'previous' && FrameDisposalModes.BACKGROUND === 'background', 'FrameDisposalModes values'); const svg = ''; - for (const [mode, size, width, height] of [[SVGScaleModes.SCALE, 2, 20, 10], [SVGScaleModes.WIDTH, 20, 20, 10], [SVGScaleModes.HEIGHT, 20, 40, 20]]) { - const rendered = await Image.renderSVG(svg, {size, mode}); - assert(rendered.width === width && rendered.height === height, `renderSVG mode ${mode}: ${rendered.width}x${rendered.height}`); + for (const [size, width, height] of [[{scale: 2}, 20, 10], [{width: 20}, 20, 10], [{height: 20}, 40, 20], [{}, 10, 5]]) { + const rendered = await Image.renderSVG(svg, size); + assert(rendered.width === width && rendered.height === height, `renderSVG ${JSON.stringify(size)}: ${rendered.width}x${rendered.height}`); } + let twoSizes = null; + try { await Image.renderSVG(svg, {width: 20, height: 20}); } catch (e) { twoSizes = e; } + assert(twoSizes instanceof TypeError, 'renderSVG: width and height together should throw a TypeError'); assert(new Image(8, 4).resize({width: 4, mode: ResizeModes.AREA}).height === 2, 'resize({width}) with a mode keeps the aspect ratio'); const photo = new Image(9, 7).fill((x, y) => Colors.fromRGBA(x * 28, y * 36, (x ^ y) * 16, 255 - x * 10)); let threw = false; @@ -1021,14 +1023,14 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); let defs = ''; for (let i = 1; i <= 9; i++) defs += `${``.repeat(10)}`; let start = Date.now(), error = null; - try { await Image.renderSVG(`${defs}`, {size: 100, mode: SVGScaleModes.WIDTH}); } catch (err) { error = err; } + try { await Image.renderSVG(`${defs}`, {width: 100}); } catch (err) { error = err; } assert(error && / references/.test(error.message) && Date.now() - start < 3000, `billion laughs should throw quickly (${error && error.message}, ${Date.now() - start} ms)`); start = Date.now(); - const dashed = await Image.renderSVG(``, {size: 100, mode: SVGScaleModes.WIDTH}); + const dashed = await Image.renderSVG(``, {width: 100}); assert(Date.now() - start < 3000 && dashed.getRGBAAt(50, 50)[3] === 255, 'a dash pattern too fine to dash should stroke solid, quickly'); - const nested = await Image.renderSVG(`${''.repeat(100000)}${''.repeat(100000)}`, {size: 100, mode: SVGScaleModes.WIDTH}); + const nested = await Image.renderSVG(`${''.repeat(100000)}${''.repeat(100000)}`, {width: 100}); assert(nested.width === 100, 'deeply nested elements should render'); } { @@ -1083,7 +1085,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); }; for (const [name, content] of Object.entries(svgs)) { const start = Date.now(); - await Image.renderSVG(`${content}`, {size: 1}).catch(() => {}); + await Image.renderSVG(`${content}`, {scale: 1}).catch(() => {}); if (Date.now() - start > 1000) panic(`SVG ${name}: took ${Date.now() - start} ms`); } } diff --git a/tests/svg-images.js b/tests/svg-images.js index 0c2aeeb1..34acf301 100644 --- a/tests/svg-images.js +++ b/tests/svg-images.js @@ -64,8 +64,8 @@ const crc32 = bytes => { // the same, with preserveAspectRatio and scaling for (const aspect of ['none', 'xMidYMid meet', 'xMinYMin slice']) { const element = (href) => ``; - const viaURI = await Image.renderSVG(svg(element(dataURI('image/png', png)), 8, 6), {size: 2}); - const viaMap = await Image.renderSVG(svg(element('logo.png'), 8, 6), {size: 2, images: {'logo.png': png}}); + const viaURI = await Image.renderSVG(svg(element(dataURI('image/png', png)), 8, 6), {scale: 2}); + const viaMap = await Image.renderSVG(svg(element('logo.png'), 8, 6), {scale: 2, images: {'logo.png': png}}); if (!same(viaURI, viaMap)) panic(`preserveAspectRatio="${aspect}" differs between data URIs and given images`); } diff --git a/tests/svg.js b/tests/svg.js index 4d3ce8a1..02974382 100644 --- a/tests/svg.js +++ b/tests/svg.js @@ -1,4 +1,4 @@ -const {Image, SVGScaleModes, Colors} = require('..'); +const {Image, Colors} = require('..'); const fs = require('fs').promises; const samePixels = require('./util/pixels'); @@ -10,7 +10,7 @@ const panic = message => { (async () => { { const svg = await fs.readFile('./tests/svgs/potato.svg'); - const image = await Image.renderSVG(svg.toString(), {size: 256 / 36, mode: SVGScaleModes.SCALE}); + const image = await Image.renderSVG(svg.toString(), {scale: 256 / 36}); const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) @@ -23,7 +23,7 @@ const panic = message => { { // gradients, clip paths, masks, style sheets, symbols, strokes and fill rules const svg = await fs.readFile('./tests/svgs/features.svg'); - const image = await Image.renderSVG(svg, {size: 480, mode: SVGScaleModes.WIDTH}); + const image = await Image.renderSVG(svg, {width: 480}); if (image.width !== 480 || image.height !== 320) panic('features: unexpected size'); const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); diff --git a/tests/util/resvg.js b/tests/util/resvg.js index 2505e3b7..f08a0f47 100644 --- a/tests/util/resvg.js +++ b/tests/util/resvg.js @@ -1,4 +1,4 @@ -const {Image, SVGScaleModes} = require('../..'); +const {Image} = require('../..'); const fs = require('fs').promises; // resvg's test fonts (tests/fonts/resvg, subsets with the characters of the test cases): its default family (serif) @@ -12,11 +12,11 @@ const fonts = async () => { // test cases of resvg (tests/svgs/resvg, see its LICENSE) next to resvg's renderings, of the directories some returns // true for. Antialiasing differs a little: at most 0.5% of the pixels may differ by more than 24 (premultiplied) module.exports = async (some, panic) => { - const root = './tests/svgs/resvg', options = {mode: SVGScaleModes.WIDTH, fonts: await fonts()}; + const root = './tests/svgs/resvg', options = {fonts: await fonts()}; const files = (await fs.readdir(root, {recursive: true})).filter(f => f.endsWith('.svg') && some(f.replace(/\\/g, '/'))); for (const file of files) { const reference = await Image.decode(await fs.readFile(`${root}/${file.replace(/\.svg$/, '.png')}`)); - const image = await Image.renderSVG(await fs.readFile(`${root}/${file}`, 'utf8'), {...options, size: reference.width}); + const image = await Image.renderSVG(await fs.readFile(`${root}/${file}`, 'utf8'), {...options, width: reference.width}); if (image.width !== reference.width || image.height !== reference.height) panic(`resvg ${file}: ${image.width}x${image.height} instead of ${reference.width}x${reference.height}`); const a = image.bitmap, b = reference.bitmap; let differing = 0; From e45b7e985d98fee12086b7699d542c591b85023b Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:33:06 +0000 Subject: [PATCH 045/188] take the text layout as plain options --- src/image.ts | 6 +++--- src/index.ts | 1 - src/text-layout.ts | 7 ++++--- tests/font.js | 13 ++++--------- tests/options-objects-all.js | 5 ++--- tests/regressions.js | 18 +++++++++--------- 6 files changed, 22 insertions(+), 28 deletions(-) diff --git a/src/image.ts b/src/image.ts index e325231b..bfb08470 100644 --- a/src/image.ts +++ b/src/image.ts @@ -393,8 +393,8 @@ export interface RenderTextOptions extends TextStyle { size: number; /** The text color. Default: `0xffffffff` */ color?: Color; - /** The text layout, as {@link TextLayoutOptions} or a {@link TextLayout}. Default: the defaults of `TextLayout` */ - layout?: TextLayoutOptions | TextLayout; + /** The text layout: wrapping, cutting off and alignment, see {@link TextLayoutOptions}. Default: `{}` */ + layout?: TextLayoutOptions; } /** Options of {@link Image.decode}, {@link Animation.decode} and {@link decode} */ @@ -2924,7 +2924,7 @@ export class Image { if (typeof text !== 'string' || !isOptions(options)) throw new TypeError('renderText() takes the text and an options object, like renderText(\'Hello\', { font, size: 32 })'); if (options.font === undefined) throw new TypeError('renderText(text, options) needs the font in the options'); const { font, color = 0xffffffff, strokeWidth = 0, strokeColor = 0x000000ff, hinting = true, features = {}, variations = {} } = options; - const layout = options.layout instanceof TextLayout ? options.layout : new TextLayout(options.layout); + const layout = new TextLayout(options.layout); const axes = textRenderer.axisValues(variations); const size = finite(options.size, 'font size'); if (size < 0) throw new RangeError(`Invalid font size: ${size}`); diff --git a/src/index.ts b/src/index.ts index ecf22763..d66e4363 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,6 @@ export type { GainMapOptions, GainMapEncodeOptions } from './gainmap.ts'; export { Animation } from './animation.ts'; export type { AnimationOptions, AnimationCanvasOptions, AnimationEncodeOptions, WebPAnimationEncodeOptions, GIFEncodeOptions, APNGEncodeOptions } from './animation.ts'; export { AnimationEncoder } from './animation-encoder.ts'; -export { TextLayout } from './text-layout.ts'; export type { TextLayoutOptions, WrapStyle, HorizontalAlign, VerticalAlign } from './text-layout.ts'; export { ImageType } from './image-type.ts'; export type { ImageTypeName } from './image-type.ts'; diff --git a/src/text-layout.ts b/src/text-layout.ts index 46c9d6ff..33d3d16d 100644 --- a/src/text-layout.ts +++ b/src/text-layout.ts @@ -7,7 +7,7 @@ export type HorizontalAlign = 'left' | 'center' | 'right'; /** Which lines to keep when the text is cut off at `maxHeight` */ export type VerticalAlign = 'top' | 'middle' | 'bottom'; -/** Options of {@link TextLayout} */ +/** The text layout of {@link Image.renderText}: wrapping, cutting off and alignment */ export interface TextLayoutOptions { /** Longer lines are wrapped. Default: `Infinity` */ maxWidth?: number; @@ -27,7 +27,8 @@ const HORIZONTAL: readonly string[] = ['left', 'center', 'right']; const VERTICAL: readonly string[] = ['top', 'middle', 'bottom']; /** - * Layout options for {@link Image.renderText} + * The layout options checked and with their defaults (internal: {@link Image.renderText} takes plain options) + * @internal */ export class TextLayout { /** Longer lines are wrapped */ @@ -48,7 +49,7 @@ export class TextLayout { * @param options See {@link TextLayoutOptions} */ constructor(options: TextLayoutOptions = {}) { - checkOptions(options, 'new TextLayout({ maxWidth: 300 })'); + checkOptions(options, 'renderText(text, { font, size, layout: { maxWidth: 300 } })'); const { maxWidth, maxHeight, wrapStyle, verticalAlign, horizontalAlign, wrapHardBreaks } = options; // (undefined and 0 mean no limit, NaN is invalid) diff --git a/tests/font.js b/tests/font.js index 67d86d3a..3ab01dd6 100644 --- a/tests/font.js +++ b/tests/font.js @@ -14,7 +14,7 @@ */ const fs = require('fs').promises; -const {Image, TextLayout} = require('..'); +const {Image} = require('..'); const samePixels = require('./util/pixels'); const panic = message => { @@ -85,7 +85,7 @@ const panic = message => { { const font = await fs.readFile('./tests/fonts/opensans bold.ttf'); // (unhinted: the layout places glyphs with subpixel precision) - const render = (text, options) => Image.renderText(text, { font, size: 40, color: 0xffffffff, layout: new TextLayout(options), hinting: false }); + const render = (text, options) => Image.renderText(text, { font, size: 40, color: 0xffffffff, layout: options, hinting: false }); // first column with ink in the given row const inkStart = (image, y) => { for (let x = 0; x < image.width; x++) @@ -373,7 +373,7 @@ const panic = message => { const fonts = await Promise.all(['opensans bold.ttf', 'noto sans hebrew subset.ttf', 'noto naskh arabic subset.ttf', 'noto sans devanagari subset.ttf'] .map(name => fs.readFile(`./tests/fonts/${name}`))); const text = 'Hi שָׁלוֹם עוֹלָם (2024) and مرحبا بالعالم!\nمرحبا بالعالم، هذا نص (2024)!\nनमस्ते क्षत्रिय र्क्ष'; - const font = await Image.renderText(text, { font: fonts, size: 40, color: 0xffffffff, layout: new TextLayout({maxWidth: 420, horizontalAlign: 'right'}) }); + const font = await Image.renderText(text, { font: fonts, size: 40, color: 0xffffffff, layout: {maxWidth: 420, horizontalAlign: 'right'} }); const encoded = await font.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) @@ -433,15 +433,10 @@ const panic = message => { } } - // a plain layout object is a TextLayout + // renderText rejects options without a font or size { const font = await fs.readFile('./tests/fonts/sourcesans3 regular.otf'); const text = 'Hello wrapped world'; - const layout = new TextLayout({maxWidth: 90, horizontalAlign: 'center'}); - const same = (a, b) => a.width === b.width && a.height === b.height && Buffer.from(a.bitmap).equals(Buffer.from(b.bitmap)); - const withLayout = await Image.renderText(text, {font, size: 24, color: 0xff8800ff, layout}); - const withOptions = await Image.renderText(text, {font, size: 24, color: 0xff8800ff, layout: {maxWidth: 90, horizontalAlign: 'center'}}); - if (!same(withLayout, withOptions)) panic('renderText: a layout object should render like a TextLayout'); for (const options of [undefined, {size: 20}, {font}, {font, size: NaN}]) { let error = null; try { await Image.renderText(text, options); } catch (err) { error = err; } diff --git a/tests/options-objects-all.js b/tests/options-objects-all.js index 97ab9a4a..203bd216 100644 --- a/tests/options-objects-all.js +++ b/tests/options-objects-all.js @@ -1,4 +1,4 @@ -const {Image, Frame, Animation, GainMap, TextLayout} = require('..'); +const {Image, Frame, Animation, GainMap} = require('..'); const panic = message => { console.error(message); @@ -29,7 +29,7 @@ const panic = message => { 'Animation.apngEncoder': bad => Animation.apngEncoder(8, 8, bad), 'AnimationEncoder.add': async bad => { const encoder = Animation.gifEncoder(16, 10); await encoder.add(photo, bad); }, 'new GainMap': bad => new GainMap(new Image(2, 2), bad), - 'new TextLayout': bad => new TextLayout(bad) + 'renderText layout': bad => Image.renderText('a', {font: new Uint8Array(0), size: 12, layout: bad}) }; for (const [name, call] of Object.entries(calls)) { for (const [label, bad] of Object.entries({'a number': 80, 'a string': 'fast', 'true': true, 'null': null})) { @@ -51,6 +51,5 @@ const panic = message => { photo.clone().quantize().quantize({colors: 4}); photo.clone().dropShadow().outline({width: 1}); if ((await photo.encodeWebP({quality: 50})).length < 10 || (await photo.encodeWebP()).length < 10) panic('encodeWebP with and without options'); - if (new TextLayout().maxWidth !== undefined && new TextLayout({maxWidth: 30}).maxWidth !== 30) panic('TextLayout options'); if (gainMap.width !== 4) panic('GainMap options'); })().catch(error => panic(error.stack || String(error))); diff --git a/tests/regressions.js b/tests/regressions.js index 7e8bb8b4..f93cd82b 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -1,5 +1,5 @@ -const {Image, Frame, Animation, TextLayout} = require('..'); -const {ResizeModes, SVGScaleModes, FrameDisposalModes, Colors} = require('..'); +const {Image, Frame, Animation} = require('..'); +const {ResizeModes, FrameDisposalModes, Colors} = require('..'); const zlib = require('zlib'); // (encoders stamp the current time into the metadata: tests that compare two encodes freeze it, or they differ when a second ends in between) const withFrozenTime = async run => { @@ -144,12 +144,12 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // text alignments only take the values of their own axis { - const current = new TextLayout({horizontalAlign: 'right', verticalAlign: 'middle'}); - assert(current.horizontalAlign === 'right' && current.verticalAlign === 'middle', 'TextLayout: names'); + const font = require('fs').readFileSync('./tests/fonts/opensans bold.ttf'); + await Image.renderText('a', {font, size: 12, layout: {horizontalAlign: 'right', verticalAlign: 'middle'}}); for (const options of [{verticalAlign: 'center', horizontalAlign: 'bottom'}, {horizontalAlign: 'top'}, {verticalAlign: 'left'}, {horizontalAlign: 'middle'}]) { let error = null; - try { new TextLayout(options); } catch (e) { error = e; } - assert(error instanceof TypeError && error.message.includes('text alignments only take the values of their own axis'), `TextLayout ${JSON.stringify(options)} should throw a TypeError, got ${error}`); + try { await Image.renderText('a', {font, size: 12, layout: options}); } catch (e) { error = e; } + assert(error instanceof TypeError && error.message.includes('text alignments only take the values of their own axis'), `layout ${JSON.stringify(options)} should throw a TypeError, got ${error}`); } } @@ -1035,7 +1035,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); } { // NaN and the infinities throw instead of drawing black, transparent or nothing; numeric strings still work - const {Frame, Animation, TextLayout} = require('..'); + const {Frame, Animation} = require('..'); const image = () => new Image(8, 8).fill(0xff0000ff); const calls = { rgbaToColor: v => Colors.fromRGBA(v, 0, 0, 255), hslaToColor: v => Colors.fromHSLA(v, 0.5, 0.5, 1), colorToRGBA: v => Colors.toRGBA(v), @@ -1061,10 +1061,10 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); } const font = require('fs').readFileSync('./tests/fonts/opensans bold.ttf'); for (const [name, call] of Object.entries({size: v => Image.renderText('a', { font, size: v }), color: v => Image.renderText('a', { font, size: 12, color: v }), - maxWidth: v => new TextLayout({maxWidth: v})})) + maxWidth: v => Image.renderText('a', { font, size: 12, layout: {maxWidth: v} })})) if (!await Promise.resolve().then(() => call(NaN)).then(() => false, e => e instanceof RangeError)) panic(`renderText: a NaN ${name} should throw a RangeError`); // (no limit is a valid layout) - new TextLayout({maxWidth: Infinity, maxHeight: Infinity}); + await Image.renderText('a', { font, size: 12, layout: {maxWidth: Infinity, maxHeight: Infinity} }); const strings = image().crop('1', '1', '4', '4').rotate('45').opacity('0.5').composite(image(), {x: '1', y: '2'}); if (strings.width !== 6) panic('numeric strings should keep working'); From 3edcc57719112e13d05db7ea06598583d0253493 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:33:06 +0000 Subject: [PATCH 046/188] rename the gain map range to min and max and check the parameters when set --- src/gainmap.ts | 173 ++++++++++++++++++++++++----------- src/image.ts | 2 +- src/util/gain.ts | 24 ++--- tests/color-matrix.js | 2 +- tests/gain-redraw.js | 14 +-- tests/gainmap.js | 44 ++++----- tests/options-objects-all.js | 2 +- tests/options-only.js | 2 +- tests/palette.js | 2 +- tests/regressions.js | 10 +- tests/transform.js | 2 +- 11 files changed, 171 insertions(+), 106 deletions(-) diff --git a/src/gainmap.ts b/src/gainmap.ts index 8db3be18..6987d0b8 100644 --- a/src/gainmap.ts +++ b/src/gainmap.ts @@ -12,10 +12,10 @@ export interface GainMapEncodeOptions { /** Options of the {@link GainMap} constructor: per channel values can be one number for all three or [r, g, b] */ export interface GainMapOptions { - /** log2 of the boost of the map's value 0 (`-1`: half as bright). Default: `0` */ - gainMapMin?: number | number[]; - /** log2 of the boost of the map's value 255 (`2`: 4 times as bright). Default: `2` */ - gainMapMax?: number | number[]; + /** log2 of the boost of the map's value 0 (`-1`: half as bright; hdrgm's GainMapMin). Default: `0` */ + min?: number | number[]; + /** log2 of the boost of the map's value 255 (`2`: 4 times as bright; hdrgm's GainMapMax). Default: `2` */ + max?: number | number[]; /** the gamma the map's values are stored with. Default: `1` */ gamma?: number | number[]; /** added to the SDR image's linear values before the boost (keeps black from being boosted forever). Default: `1/64` */ @@ -24,7 +24,7 @@ export interface GainMapOptions { offsetHDR?: number | number[]; /** log2 of the display headroom from which on the map starts to apply. Default: `0` */ hdrCapacityMin?: number; - /** log2 of the display headroom at which it applies fully. Default: `gainMapMax`'s largest value */ + /** log2 of the display headroom at which it applies fully. Default: `max`'s largest value */ hdrCapacityMax?: number; /** whether the boost applies in the SDR image's color space (`false`: in the HDR image's). Default: `true` */ useBaseColorSpace?: boolean; @@ -44,25 +44,96 @@ const perChannel = (value: number | number[], name: string): number[] => { * {@link Image.decode} reads the gain maps of JPEGs, AVIFs and JPEG XLs into {@link Image.gainMap}, * {@link Image.encodeJPEG}, {@link Image.encodeAVIF} and {@link Image.encodeJXL} write them. The map covers the whole image, at any resolution (phones * write a quarter of the width and height). Its value `v` (0-255, per channel or one for all) boosts the linear - * light of the image by `2^(gainMapMin + (gainMapMax - gainMapMin) * (v / 255)^(1 / gamma))` + * light of the image by `2^(min + (max - min) * (v / 255)^(1 / gamma))`. + * + * The parameters are checked when they are set: a value that isn't finite (or an array of other than 1 or 3 of them) + * throws, and the map keeps its value */ export class GainMap { - /** log2 of the boost of the map's value 0, per channel (red, green, blue) */ - gainMapMin: number[]; - /** log2 of the boost of the map's value 255, per channel */ - gainMapMax: number[]; - /** the gamma the map's values are stored with, per channel */ - gamma: number[]; - /** added to the SDR image's linear values before the boost, per channel */ - offsetSDR: number[]; - /** subtracted from the HDR result, per channel */ - offsetHDR: number[]; - /** log2 of the display headroom from which on the map starts to apply */ - hdrCapacityMin: number; - /** log2 of the display headroom at which it applies fully */ - hdrCapacityMax: number; - /** whether the boost applies in the SDR image's color space (else in the HDR image's) */ - useBaseColorSpace: boolean; + /** @internal @ignore the parameters (the accessors check and copy them) */ + __min__!: number[]; + /** @internal @ignore */ + __max__!: number[]; + /** @internal @ignore */ + __gamma__!: number[]; + /** @internal @ignore */ + __offsetSDR__!: number[]; + /** @internal @ignore */ + __offsetHDR__!: number[]; + /** @internal @ignore */ + __capacityMin__!: number; + /** @internal @ignore */ + __capacityMax__!: number; + /** @internal @ignore */ + __useBaseColorSpace__!: boolean; + + /** log2 of the boost of the map's value 0, per channel (red, green, blue; a copy: assign to change it) */ + get min(): number[] { return this.__min__.slice(); } + /** + * Replaces the value (checked: an invalid one throws and the old one stays) + * @param value One number for all channels or [r, g, b] + */ + set min(value: number | number[]) { this.__min__ = perChannel(value, 'min'); } + /** log2 of the boost of the map's value 255, per channel (a copy: assign to change it) */ + get max(): number[] { return this.__max__.slice(); } + /** + * Replaces the value (checked: an invalid one throws and the old one stays) + * @param value One number for all channels or [r, g, b] + */ + set max(value: number | number[]) { this.__max__ = perChannel(value, 'max'); } + /** The gamma the map's values are stored with, per channel (a copy: assign to change it) */ + get gamma(): number[] { return this.__gamma__.slice(); } + /** + * Replaces the value (checked: an invalid one throws and the old one stays) + * @param value One number above 0 for all channels or [r, g, b] + */ + set gamma(value: number | number[]) { + const gamma = perChannel(value, 'gamma'); + if (gamma.some(g => g <= 0)) throw new RangeError('The gain map\'s gamma must be above 0'); + this.__gamma__ = gamma; + } + /** Added to the SDR image's linear values before the boost, per channel (a copy: assign to change it) */ + get offsetSDR(): number[] { return this.__offsetSDR__.slice(); } + /** + * Replaces the value (checked: an invalid one throws and the old one stays) + * @param value One number for all channels or [r, g, b] + */ + set offsetSDR(value: number | number[]) { this.__offsetSDR__ = perChannel(value, 'offsetSDR'); } + /** Subtracted from the HDR result, per channel (a copy: assign to change it) */ + get offsetHDR(): number[] { return this.__offsetHDR__.slice(); } + /** + * Replaces the value (checked: an invalid one throws and the old one stays) + * @param value One number for all channels or [r, g, b] + */ + set offsetHDR(value: number | number[]) { this.__offsetHDR__ = perChannel(value, 'offsetHDR'); } + /** log2 of the display headroom from which on the map starts to apply (from 0) */ + get hdrCapacityMin(): number { return this.__capacityMin__; } + /** + * Replaces the value (checked: an invalid one throws and the old one stays) + * @param value A finite number from 0 + */ + set hdrCapacityMin(value: number) { + const capacity = finite(value, 'hdrCapacityMin'); + if (capacity < 0) throw new RangeError('The gain map\'s hdrCapacityMin can\'t be negative'); + this.__capacityMin__ = capacity; + } + /** log2 of the display headroom at which it applies fully (above `hdrCapacityMin`) */ + get hdrCapacityMax(): number { return this.__capacityMax__; } + /** + * Replaces the value (checked: an invalid one throws and the old one stays) + * @param value A finite number + */ + set hdrCapacityMax(value: number) { this.__capacityMax__ = finite(value, 'hdrCapacityMax'); } + /** Whether the boost applies in the SDR image's color space (else in the HDR image's) */ + get useBaseColorSpace(): boolean { return this.__useBaseColorSpace__; } + /** + * Replaces the value (checked: an invalid one throws and the old one stays) + * @param value Whether the boost applies in the SDR image's color space + */ + set useBaseColorSpace(value: boolean) { + if (typeof value !== 'boolean') throw new TypeError('useBaseColorSpace must be a boolean'); + this.__useBaseColorSpace__ = value; + } /** its pixels */ private __pixels__: Image | null = null; @@ -74,17 +145,17 @@ export class GainMap { * @param options See {@link GainMapOptions} */ constructor(image: Image, options: GainMapOptions = {}) { - checkOptions(options, 'new GainMap(image, { gainMapMax: 2 })'); + checkOptions(options, 'new GainMap(image, { max: 2 })'); if (!(image instanceof Image)) throw new TypeError('The gain map has to be an Image'); - const { gainMapMin = 0, gainMapMax = 2, gamma = 1, offsetSDR = 1 / 64, offsetHDR = 1 / 64, hdrCapacityMin = 0, useBaseColorSpace = true } = options; - this.gainMapMin = perChannel(gainMapMin, 'gainMapMin'); - this.gainMapMax = perChannel(gainMapMax, 'gainMapMax'); - this.gamma = perChannel(gamma, 'gamma'); - this.offsetSDR = perChannel(offsetSDR, 'offsetSDR'); - this.offsetHDR = perChannel(offsetHDR, 'offsetHDR'); - this.hdrCapacityMin = finite(hdrCapacityMin, 'hdrCapacityMin'); - this.hdrCapacityMax = finite(options.hdrCapacityMax ?? Math.max(...this.gainMapMax), 'hdrCapacityMax'); - this.useBaseColorSpace = Boolean(useBaseColorSpace); + const { min = 0, max = 2, gamma = 1, offsetSDR = 1 / 64, offsetHDR = 1 / 64, hdrCapacityMin = 0, useBaseColorSpace = true } = options; + this.min = min; + this.max = max; + this.gamma = gamma; + this.offsetSDR = offsetSDR; + this.offsetHDR = offsetHDR; + this.hdrCapacityMin = hdrCapacityMin; + this.hdrCapacityMax = options.hdrCapacityMax ?? Math.max(...this.__max__); + this.useBaseColorSpace = useBaseColorSpace; this.__pixels__ = image; this.__check__(); } @@ -127,36 +198,30 @@ export class GainMap { } private __set_params__(p: codec.Params): void { - this.gainMapMin = p.min.slice(); - this.gainMapMax = p.max.slice(); - this.gamma = p.gamma.slice(); - this.offsetSDR = p.offsetSDR.slice(); - this.offsetHDR = p.offsetHDR.slice(); - this.hdrCapacityMin = p.capacityMin; - this.hdrCapacityMax = p.capacityMax; - this.useBaseColorSpace = p.useBaseColorSpace; + this.__min__ = p.min.slice(); + this.__max__ = p.max.slice(); + this.__gamma__ = p.gamma.slice(); + this.__offsetSDR__ = p.offsetSDR.slice(); + this.__offsetHDR__ = p.offsetHDR.slice(); + this.__capacityMin__ = p.capacityMin; + this.__capacityMax__ = p.capacityMax; + this.__useBaseColorSpace__ = p.useBaseColorSpace; } /** @internal the parameters, checked */ __params__(): codec.Params { this.__check__(); return { - min: this.gainMapMin.slice(), max: this.gainMapMax.slice(), gamma: this.gamma.slice(), offsetSDR: this.offsetSDR.slice(), - offsetHDR: this.offsetHDR.slice(), capacityMin: this.hdrCapacityMin, capacityMax: this.hdrCapacityMax, useBaseColorSpace: this.useBaseColorSpace + min: this.__min__.slice(), max: this.__max__.slice(), gamma: this.__gamma__.slice(), offsetSDR: this.__offsetSDR__.slice(), + offsetHDR: this.__offsetHDR__.slice(), capacityMin: this.__capacityMin__, capacityMax: this.__capacityMax__, useBaseColorSpace: this.__useBaseColorSpace__ }; } private __check__(): void { - const p: codec.Params = { - min: perChannel(this.gainMapMin, 'gainMapMin'), max: perChannel(this.gainMapMax, 'gainMapMax'), gamma: perChannel(this.gamma, 'gamma'), - offsetSDR: perChannel(this.offsetSDR, 'offsetSDR'), offsetHDR: perChannel(this.offsetHDR, 'offsetHDR'), - capacityMin: finite(this.hdrCapacityMin, 'hdrCapacityMin'), capacityMax: finite(this.hdrCapacityMax, 'hdrCapacityMax'), useBaseColorSpace: this.useBaseColorSpace - }; - for (let c = 0; c < 3; c++) { - if (p.gamma[c] <= 0) throw new RangeError('The gain map\'s gamma must be above 0'); - if (p.max[c] < p.min[c]) throw new RangeError('The gain map\'s gainMapMax must be at least its gainMapMin'); - } - if (p.capacityMin < 0 || p.capacityMax <= p.capacityMin) throw new RangeError('The gain map\'s hdrCapacityMax must be above its hdrCapacityMin, which can\'t be negative'); + // (each value is checked when it is set; these are the checks between them) + for (let c = 0; c < 3; c++) + if (this.__max__[c] < this.__min__[c]) throw new RangeError('The gain map\'s max must be at least its min'); + if (this.__capacityMax__ <= this.__capacityMin__) throw new RangeError('The gain map\'s hdrCapacityMax must be above its hdrCapacityMin'); } /** The map's pixels. Changes to them are written when the image is encoded */ @@ -214,9 +279,9 @@ export class GainMap { /** @internal the stored values (0-255, per channel) that boost by nothing (log2 gain 0), clamped to the range */ __neutral__(): number[] { return [0, 1, 2].map(c => { - const min = this.gainMapMin[c], max = this.gainMapMax[c]; + const min = this.__min__[c], max = this.__max__[c]; const t = max > min ? Math.min(1, Math.max(0, -min / (max - min))) : 0; - return Math.round(255 * t ** this.gamma[c]); + return Math.round(255 * t ** this.__gamma__[c]); }); } diff --git a/src/image.ts b/src/image.ts index bfb08470..4a737644 100644 --- a/src/image.ts +++ b/src/image.ts @@ -1619,7 +1619,7 @@ export class Image { } out[c] = share * Hs + (1 - share) * Hd; } - }, map.channels === 1 && (!sourceMap || sourceMap.channels === 1), Math.max(0, ...(sourceMap?.gainMapMax ?? [0]))); + }, map.channels === 1 && (!sourceMap || sourceMap.channels === 1), Math.max(0, ...(sourceMap?.__max__ ?? [0]))); return this; } diff --git a/src/util/gain.ts b/src/util/gain.ts index 8a0c4297..09b85315 100644 --- a/src/util/gain.ts +++ b/src/util/gain.ts @@ -14,7 +14,7 @@ const LUMA = [0.2126, 0.7152, 0.0722]; // per channel: the log2 gain of a stored value const gainTables = (map: GainMap): Float32Array[] => [0, 1, 2].map(c => { - const min = map.gainMapMin[c], max = map.gainMapMax[c], gamma = map.gamma[c]; + const min = map.__min__[c], max = map.__max__[c], gamma = map.__gamma__[c]; return new Float32Array(256).map((_, v) => min + (max - min) * (v / 255) ** (1 / gamma)); }); @@ -36,7 +36,7 @@ export function decodeGains(map: GainMap): Float32Array { * values), one value per pixel for a single channel map */ export function encodeGains(map: GainMap, gains: Float32Array, width: number, height: number, single: boolean): void { - const min = map.gainMapMin.slice(), max = map.gainMapMax.slice(); + const min = map.__min__.slice(), max = map.__max__.slice(); for (let g = 0; g < gains.length; g += 3) for (let c = 0; c < 3; c++) { const v = gains[g + c]; @@ -47,17 +47,17 @@ export function encodeGains(map: GainMap, gains: Float32Array, width: number, he const lo = Math.min(...min), hi = Math.max(...max); min.fill(lo); max.fill(hi); - const gamma = map.gamma[0]; - map.gamma = [gamma, gamma, gamma]; + const gamma = map.__gamma__[0]; + map.__gamma__ = [gamma, gamma, gamma]; } - map.gainMapMin = min; - map.gainMapMax = max; + map.__min__ = min; + map.__max__ = max; const image = map.image.width === width && map.image.height === height ? map.image : new Image(width, height); const out = image.bitmap; for (let i = 0, g = 0; i < out.length; i += 4, g += 3) { for (let c = 0; c < 3; c++) { const t = max[c] > min[c] ? (gains[g + c] - min[c]) / (max[c] - min[c]) : 0; - out[i + c] = 255 * Math.min(1, Math.max(0, t)) ** map.gamma[c]; + out[i + c] = 255 * Math.min(1, Math.max(0, t)) ** map.__gamma__[c]; } out[i + 3] = 255; } @@ -80,8 +80,8 @@ export function neutralMap(like: GainMap, likeWidth: number, width: number, heig } map.image = image; // (no boost has to be in the range) - map.gainMapMin = map.gainMapMin.map(v => Math.min(v, 0)); - map.gainMapMax = map.gainMapMax.map(v => Math.max(v, 0)); + map.__min__ = map.__min__.map(v => Math.min(v, 0)); + map.__max__ = map.__max__.map(v => Math.max(v, 0)); return map; } @@ -105,7 +105,7 @@ export function redraw(image: Image, map: GainMap, left: number, top: number, ri const mapWidth = map.width, mapHeight = map.height; const sx = mapWidth / width, sy = mapHeight / height; const oS = map.offsetSDR, oH = map.offsetHDR; - const lo = Math.min(0, ...map.gainMapMin), hi = Math.max(0, ...map.gainMapMax, extraMax); + const lo = Math.min(0, ...map.__min__), hi = Math.max(0, ...map.__max__, extraMax); const S = [0, 0, 0], H = [0, 0, 0], sumS = [0, 0, 0], sumH = [0, 0, 0]; const i0 = Math.max(0, Math.floor(left * sx)), i1 = Math.min(mapWidth, Math.ceil(right * sx)); const j0 = Math.max(0, Math.floor(top * sy)), j1 = Math.min(mapHeight, Math.ceil(bottom * sy)); @@ -148,7 +148,7 @@ export function redraw(image: Image, map: GainMap, left: number, top: number, ri // when the new gains are in the map's range (usual: drawing seldom brightens beyond the highlights), only those // pixels are stored and the others stay as they are - const { gainMapMin: min, gainMapMax: max, gamma } = map; + const { __min__: min, __max__: max, gamma } = map; let fits = !single || (min[0] === min[1] && min[1] === min[2] && max[0] === max[1] && max[1] === max[2] && gamma[0] === gamma[1] && gamma[1] === gamma[2]); for (let g = 0; fits && g < next.length; g += 3) for (let c = 0; c < 3; c++) if (!(next[g + c] >= min[c] && next[g + c] <= max[c])) fits = false; @@ -251,7 +251,7 @@ export function recolor(map: GainMap, before: Image, apply: (proxy: Image) => vo } let changedPeak = 1; for (let g = 0; g < changed.length; g++) changedPeak = Math.max(changedPeak, changed[g]); - const lo = Math.min(0, ...map.gainMapMin), hi = Math.max(Math.log2(Math.max(peak, changedPeak)), ...map.gainMapMax); + const lo = Math.min(0, ...map.__min__), hi = Math.max(Math.log2(Math.max(peak, changedPeak)), ...map.__max__); const single = map.channels === 1; for (let g = 0; g < gains.length; g += 3) { if (single) { diff --git a/tests/color-matrix.js b/tests/color-matrix.js index d3be46a6..8bd3ba8f 100644 --- a/tests/color-matrix.js +++ b/tests/color-matrix.js @@ -56,7 +56,7 @@ const IDENTITY = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0]; check(frame instanceof Frame && frame.duration === 40 && frame.xOffset === 2 && frame.yOffset === 3 && same(frame, photo(4, 4).sepia()), 'colorMatrix on a frame'); const hdr = photo(8, 8); - hdr.gainMap = new GainMap(new Image(4, 4).fill(0xc0c0c0ff), {gainMapMin: 0, gainMapMax: 2}); + hdr.gainMap = new GainMap(new Image(4, 4).fill(0xc0c0c0ff), {min: 0, max: 2}); hdr.colorMatrix(sepia); check(hdr.gainMap && hdr.gainMap.width === 4 && hdr.gainMap.height === 4, 'colorMatrix with a gain map keeps it'); check((await Image.decode(await hdr.encodeJPEG())).gainMap, 'colorMatrix: the gain map is still encoded'); diff --git a/tests/gain-redraw.js b/tests/gain-redraw.js index 92da6a5a..5fbab8f5 100644 --- a/tests/gain-redraw.js +++ b/tests/gain-redraw.js @@ -6,7 +6,7 @@ const panic = message => { process.exit(1); }; // the map's log2 gain at a map pixel (red channel) -const gain = (map, x, y) => map.gainMapMin[0] + (map.gainMapMax[0] - map.gainMapMin[0]) * (map.image.bitmap[4 * (y * map.width + x)] / 255) ** (1 / map.gamma[0]); +const gain = (map, x, y) => map.min[0] + (map.max[0] - map.min[0]) * (map.image.bitmap[4 * (y * map.width + x)] / 255) ** (1 / map.gamma[0]); const mapBytes = image => Buffer.from(image.gainMap.image.bitmap); (async () => { @@ -15,11 +15,11 @@ const mapBytes = image => Buffer.from(image.gainMap.image.bitmap); // drawing in a small area only changes the map where it was drawn: the rest of the map keeps its stored values, // and the range stays { - const image = await Image.decode(file), before = mapBytes(image), range = [image.gainMap.gainMapMin.slice(), image.gainMap.gainMapMax.slice()]; + const image = await Image.decode(file), before = mapBytes(image), range = [image.gainMap.min.slice(), image.gainMap.max.slice()]; const map = image.gainMap, sx = map.width / image.width, sy = map.height / image.height; const box = {x: 100, y: 80, width: 60, height: 40}; - image.drawBox(box.x, box.y, box.width, box.height, 0x000000ff); - if (JSON.stringify([map.gainMapMin, map.gainMapMax]) !== JSON.stringify(range)) panic('drawBox: the range of the map changed'); + image.composite(new Image(box.width, box.height).fill(0x000000ff), {x: box.x, y: box.y}); + if (JSON.stringify([map.min, map.max]) !== JSON.stringify(range)) panic('composite: the range of the map changed'); const after = mapBytes(image); let changed = 0; for (let y = 0; y < map.height; y++) for (let x = 0; x < map.width; x++) { @@ -49,14 +49,14 @@ const mapBytes = image => Buffer.from(image.gainMap.image.bitmap); { const image = await Image.decode(file), map = image.gainMap; const before = Array.from({length: 20}, (_, i) => gain(map, i * 7 % map.width, i * 11 % map.height)); - const max = map.gainMapMax[0]; + const max = map.max[0]; // an HDR source with a stronger map, composited over a part of the image const source = new Image(80, 80).fill(0xffffffff); source.gainMap = (await Image.decode(file)).gainMap.clone(); - source.gainMap.gainMapMax = [max + 2, max + 2, max + 2]; + source.gainMap.max = [max + 2, max + 2, max + 2]; source.gainMap.image.fill(0xffffffff); image.composite(source, {x: 10, y: 10}); - if (!(map.gainMapMax[0] > max)) panic(`composite: the range should have grown beyond ${max}, it is ${map.gainMapMax[0]}`); + if (!(map.max[0] > max)) panic(`composite: the range should have grown beyond ${max}, it is ${map.max[0]}`); for (let i = 0; i < 20; i++) { const x = i * 7 % map.width, y = i * 11 % map.height; // (not where the source went: the map pixels of the image's 10 to 90) diff --git a/tests/gainmap.js b/tests/gainmap.js index 958ed0c5..8713a21c 100644 --- a/tests/gainmap.js +++ b/tests/gainmap.js @@ -8,7 +8,7 @@ const panic = message => { const close = (a, b, epsilon = 1e-4) => a.every((v, i) => Math.abs(v - b[i]) <= epsilon); const same = (a, b) => Buffer.from(a.bitmap).equals(Buffer.from(b.bitmap)); // the map's value for a gain (log2), channel 0 -const stored = (map, gain) => 255 * Math.min(1, Math.max(0, (gain - map.gainMapMin[0]) / (map.gainMapMax[0] - map.gainMapMin[0]))) ** map.gamma[0]; +const stored = (map, gain) => 255 * Math.min(1, Math.max(0, (gain - map.min[0]) / (map.max[0] - map.min[0]))) ** map.gamma[0]; // the mean of the map's red channel over an area of the image const meanValue = (image, left, top, right, bottom) => { const map = image.gainMap.image, sx = map.width / image.width, sy = map.height / image.height; @@ -32,11 +32,11 @@ const meanValue = (image, left, top, right, bottom) => { const map = image.gainMap; if (!(map instanceof GainMap)) panic(`${name}: no gain map`); if (map.channels !== channels || map.width !== 512 || map.height !== 384) panic(`${name}: ${map.channels} channels, ${map.width}x${map.height}`); - if (Math.abs(map.gainMapMax[0] - 2.1814) > 1e-3 || Math.abs(map.hdrCapacityMax - 2.3) > 1e-3 || map.hdrCapacityMin !== 0 || map.gamma[0] !== 1 || map.useBaseColorSpace) - panic(`${name}: parameters ${map.gainMapMax} ${map.hdrCapacityMax} ${map.gamma} ${map.useBaseColorSpace}`); + if (Math.abs(map.max[0] - 2.1814) > 1e-3 || Math.abs(map.hdrCapacityMax - 2.3) > 1e-3 || map.hdrCapacityMin !== 0 || map.gamma[0] !== 1 || map.useBaseColorSpace) + panic(`${name}: parameters ${map.max} ${map.hdrCapacityMax} ${map.gamma} ${map.useBaseColorSpace}`); if (!same(image, await Image.decode(files[name], {gainMap: false}))) panic(`${name}: the base differs with the gain map`); } - if (single.gainMap.gainMapMin[0] > -0.47 || single.gainMap.gainMapMin[0] < -0.49) panic(`single-channel: gainMapMin ${single.gainMap.gainMapMin[0]}`); + if (single.gainMap.min[0] > -0.47 || single.gainMap.min[0] < -0.49) panic(`single-channel: min ${single.gainMap.min[0]}`); if ((await Image.decode(files['single-channel'], {gainMap: false})).gainMap !== null) panic('gainMap: false still read the map'); // a cut off map (the MPF index pointing past the end) and a broken ISO block: no map, the base decodes @@ -52,7 +52,7 @@ const meanValue = (image, left, top, right, bottom) => { const encoded = await image.encodeJPEG({quality: 90}); const plain = await image.encodeJPEG({quality: 90, gainMap: false}); const back = await Image.decode(encoded); - if (!back.gainMap || !close(back.gainMap.gainMapMax, image.gainMap.gainMapMax) || !close(back.gainMap.gainMapMin, image.gainMap.gainMapMin) || back.gainMap.hdrCapacityMax !== image.gainMap.hdrCapacityMax) + if (!back.gainMap || !close(back.gainMap.max, image.gainMap.max) || !close(back.gainMap.min, image.gainMap.min) || back.gainMap.hdrCapacityMax !== image.gainMap.hdrCapacityMax) panic(`${name}: the written map's parameters differ`); { let sum = 0; @@ -83,19 +83,19 @@ const meanValue = (image, left, top, right, bottom) => { const xmpOnly = Buffer.concat([alone.subarray(0, start), alone.subarray(start + 2 + length)]); if (xmpOnly.includes('urn:iso:std:iso:ts:21496:-1') || !xmpOnly.includes('hdrgm:GainMapMax')) panic('XMP only map: test file wrong'); const map = await GainMap.decode(xmpOnly); - if (!close(map.gainMapMax, single.gainMap.gainMapMax) || !close(map.gainMapMin, single.gainMap.gainMapMin) || Math.abs(map.hdrCapacityMax - single.gainMap.hdrCapacityMax) > 1e-5) - panic(`XMP only map: ${map.gainMapMin} ${map.gainMapMax} ${map.hdrCapacityMax} vs ${single.gainMap.gainMapMin} ${single.gainMap.gainMapMax}`); + if (!close(map.max, single.gainMap.max) || !close(map.min, single.gainMap.min) || Math.abs(map.hdrCapacityMax - single.gainMap.hdrCapacityMax) > 1e-5) + panic(`XMP only map: ${map.min} ${map.max} ${map.hdrCapacityMax} vs ${single.gainMap.min} ${single.gainMap.max}`); } // made by hand: parameters checked, written and read back; per channel values { const image = new Image(64, 48).fill(0x808080ff); - image.gainMap = new GainMap(new Image(16, 12).fill((x, y) => Colors.fromRGBA(x * 16, y * 20, 255 - x * 16, 255)), {gainMapMax: [2, 3, 4], gainMapMin: -1, gamma: 2}); + image.gainMap = new GainMap(new Image(16, 12).fill((x, y) => Colors.fromRGBA(x * 16, y * 20, 255 - x * 16, 255)), {max: [2, 3, 4], min: -1, gamma: 2}); const back = await Image.decode(await image.encodeJPEG({quality: 95})); const map = back.gainMap; - if (!map || map.channels !== 3 || !close(map.gainMapMax, [2, 3, 4]) || !close(map.gainMapMin, [-1, -1, -1]) || !close(map.gamma, [2, 2, 2]) || map.hdrCapacityMax !== 4 || map.width !== 16) - panic(`hand made map: ${map ? [map.channels, map.gainMapMin, map.gainMapMax, map.gamma, map.hdrCapacityMax, map.width] : 'none'}`); - for (const [options, error] of [[{gamma: 0}, RangeError], [{gainMapMax: -2}, RangeError], [{gainMapMax: [1, 2]}, RangeError], [{gainMapMax: NaN}, RangeError], [{hdrCapacityMin: 3, hdrCapacityMax: 2}, RangeError]]) + if (!map || map.channels !== 3 || !close(map.max, [2, 3, 4]) || !close(map.min, [-1, -1, -1]) || !close(map.gamma, [2, 2, 2]) || map.hdrCapacityMax !== 4 || map.width !== 16) + panic(`hand made map: ${map ? [map.channels, map.min, map.max, map.gamma, map.hdrCapacityMax, map.width] : 'none'}`); + for (const [options, error] of [[{gamma: 0}, RangeError], [{max: -2}, RangeError], [{max: [1, 2]}, RangeError], [{max: NaN}, RangeError], [{hdrCapacityMin: 3, hdrCapacityMax: 2}, RangeError]]) try { new GainMap(new Image(4, 4), options); panic(`GainMap ${JSON.stringify(options)} didn't throw`); @@ -152,7 +152,7 @@ const meanValue = (image, left, top, right, bottom) => { const neutral = stored(image.gainMap, 0); if (Math.abs(meanValue(image, 204, 104, 296, 196) - neutral) > 1.5) panic(`composite: ${meanValue(image, 204, 104, 296, 196)} under the box, no boost is ${neutral}`); // (the range can grow: compared as gains) - const gainAt = (img, value) => img.gainMap.gainMapMin[0] + (img.gainMap.gainMapMax[0] - img.gainMap.gainMapMin[0]) * (value / 255) ** (1 / img.gainMap.gamma[0]); + const gainAt = (img, value) => img.gainMap.min[0] + (img.gainMap.max[0] - img.gainMap.min[0]) * (value / 255) ** (1 / img.gainMap.gamma[0]); const outside = gainAt(image, meanValue(image, 0, 0, 512, 90)), before = gainAt(single, meanValue(single, 0, 0, 512, 90)); if (Math.abs(outside - before) > 0.02) panic(`composite: changed outside the box (${outside} vs ${before})`); const boxed = single.clone().drawBox(0, 0, 64, 64, 0xffffffff); @@ -217,18 +217,18 @@ const meanValue = (image, left, top, right, bottom) => { for (const [name, file, headroom] of [['XMP headroom', await appleFile('4', null), 4], ['maker note headroom', await appleFile(null, exif), 2 ** 1.8]]) { const image = await Image.decode(file); const map = image.gainMap; - if (!map || Math.abs(map.gainMapMax[0] - Math.log2(headroom)) > 1e-4 || map.gainMapMin[0] !== 0 || map.hdrCapacityMax !== map.gainMapMax[0]) panic(`Apple, ${name}: ${map ? map.gainMapMax : 'no map'}`); + if (!map || Math.abs(map.max[0] - Math.log2(headroom)) > 1e-4 || map.min[0] !== 0 || map.hdrCapacityMax !== map.max[0]) panic(`Apple, ${name}: ${map ? map.max : 'no map'}`); // the ramp's values as Apple applies them const px = map.image.bitmap; for (let x = 0; x < 64; x += 9) { const stored = Math.min(255, x * 4) / 255; const want = Math.log2(1 + (headroom - 1) * stored ** 1.961); - const got = map.gainMapMax[0] * px[4 * x] / 255; + const got = map.max[0] * px[4 * x] / 255; if (Math.abs(want - got) > 0.03) panic(`Apple, ${name}: value ${x * 4} gives ${got} stops, not ${want}`); } // written as an Ultra HDR JPEG (ISO 21496-1 and hdrgm), the same boosts const back = (await Image.decode(await image.encodeJPEG({quality: 90}))).gainMap; - if (!back || !close(back.gainMapMax, map.gainMapMax)) panic(`Apple, ${name}: not written back`); + if (!back || !close(back.max, map.max)) panic(`Apple, ${name}: not written back`); } } @@ -237,8 +237,8 @@ const meanValue = (image, left, top, right, bottom) => { const avif = await image.encodeAVIF({quality: 80}); const back = await Image.decode(avif); const map = back.gainMap; - if (!map || map.channels !== image.gainMap.channels || map.width !== image.gainMap.width || !close(map.gainMapMax, image.gainMap.gainMapMax) || !close(map.gainMapMin, image.gainMap.gainMapMin) || Math.abs(map.hdrCapacityMax - image.gainMap.hdrCapacityMax) > 1e-6) - panic(`AVIF ${name}: ${map ? `${map.channels} channels, ${map.width}, ${map.gainMapMax}` : 'no gain map'}`); + if (!map || map.channels !== image.gainMap.channels || map.width !== image.gainMap.width || !close(map.max, image.gainMap.max) || !close(map.min, image.gainMap.min) || Math.abs(map.hdrCapacityMax - image.gainMap.hdrCapacityMax) > 1e-6) + panic(`AVIF ${name}: ${map ? `${map.channels} channels, ${map.width}, ${map.max}` : 'no gain map'}`); let sum = 0; const a = map.image.bitmap, b = image.gainMap.image.bitmap; for (let i = 0; i < a.length; i += 4) sum += Math.abs(a[i] - b[i]); @@ -256,8 +256,8 @@ const meanValue = (image, left, top, right, bottom) => { const jxl = await image.encodeJXL(options); const back = await Image.decode(jxl); const map = back.gainMap; - if (!map || map.channels !== image.gainMap.channels || map.width !== image.gainMap.width || !close(map.gainMapMax, image.gainMap.gainMapMax) || !close(map.gainMapMin, image.gainMap.gainMapMin) || Math.abs(map.hdrCapacityMax - image.gainMap.hdrCapacityMax) > 1e-6) - panic(`JXL ${name} ${JSON.stringify(options)}: ${map ? `${map.channels} channels, ${map.width}, ${map.gainMapMax}` : 'no gain map'}`); + if (!map || map.channels !== image.gainMap.channels || map.width !== image.gainMap.width || !close(map.max, image.gainMap.max) || !close(map.min, image.gainMap.min) || Math.abs(map.hdrCapacityMax - image.gainMap.hdrCapacityMax) > 1e-6) + panic(`JXL ${name} ${JSON.stringify(options)}: ${map ? `${map.channels} channels, ${map.width}, ${map.max}` : 'no gain map'}`); let sum = 0; const a = map.image.bitmap, b = image.gainMap.image.bitmap; for (let i = 0; i < a.length; i += 4) sum += Math.abs(a[i] - b[i]); @@ -284,17 +284,17 @@ const meanValue = (image, left, top, right, bottom) => { { const gainsOf = map => { const px = map.image.bitmap, out = []; - for (let i = 0; i < px.length; i += 4) out.push(map.gainMapMin[0] + (map.gainMapMax[0] - map.gainMapMin[0]) * (px[i] / 255) ** (1 / map.gamma[0])); + for (let i = 0; i < px.length; i += 4) out.push(map.min[0] + (map.max[0] - map.min[0]) * (px[i] / 255) ** (1 / map.gamma[0])); return out; }; const bright = new Image(64, 64).fill((x, y) => ((y * 4) * 0x1010100 | 255) >>> 0); - bright.gainMap = new GainMap(new Image(64, 64).fill(x => ((x * 4) * 0x1010100 | 255) >>> 0), {gainMapMin: 0, gainMapMax: 2, offsetSDR: 0, offsetHDR: 0}); + bright.gainMap = new GainMap(new Image(64, 64).fill(x => ((x * 4) * 0x1010100 | 255) >>> 0), {min: 0, max: 2, offsetSDR: 0, offsetHDR: 0}); for (const [name, edit] of [['contrast 1.4', i => i.contrast(1.4)], ['contrast 0.6', i => i.contrast(0.6)], ['brightness 1.3', i => i.brightness(1.3)], ['gamma 1.5', i => i.gamma(1.5)], ['lightness 1.2', i => i.lightness(1.2)]]) { const out = edit(bright.clone()); const lowest = Math.min(...gainsOf(out.gainMap)); if (lowest < -0.05) panic(`${name}: the HDR rendition got darker than SDR (gain ${lowest})`); } - if (!(Math.max(...bright.clone().contrast(1.4).gainMap.gainMapMax) > 2)) panic('contrast 1.4: the highlights didn\'t stretch'); + if (!(Math.max(...bright.clone().contrast(1.4).gainMap.max) > 2)) panic('contrast 1.4: the highlights didn\'t stretch'); const inverted = bright.clone().invert().gainMap; if (gainsOf(inverted).some(g => Math.abs(g) > 0.02)) panic('invert: the map still boosts'); } diff --git a/tests/options-objects-all.js b/tests/options-objects-all.js index 203bd216..42e37cab 100644 --- a/tests/options-objects-all.js +++ b/tests/options-objects-all.js @@ -10,7 +10,7 @@ const panic = message => { (async () => { const photo = new Image(16, 10).fill((x, y) => 0x336699ff + x * 0x01000000 + y * 0x00010000 >>> 0); const frame = Frame.from(photo); - const gainMap = new GainMap(new Image(4, 3).fill(0x808080ff), {gainMapMax: 2}); + const gainMap = new GainMap(new Image(4, 3).fill(0x808080ff), {max: 2}); const calls = { 'quantize': bad => photo.clone().quantize(bad), 'transform': bad => photo.clone().transform([1, 0, 0, 1, 0, 0], bad), diff --git a/tests/options-only.js b/tests/options-only.js index 364bb85a..61ea5b84 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -80,7 +80,7 @@ const sameImage = (image, reference, label) => { // the encoders take their values in the options object only; the metadata goes in { metadata } const makeAnimation = () => new Animation([new Frame(4, 4)]); - const gainMap = new GainMap(new Image(4, 4).fill(0x808080ff), {gainMapMin: 0, gainMapMax: 2}); + const gainMap = new GainMap(new Image(4, 4).fill(0x808080ff), {min: 0, max: 2}); for (const [label, call] of Object.entries({ 'encodePNG(1)': () => photo.encodePNG(1), 'encodePNG(1, { author })': () => photo.encodePNG(1, {author: 'me'}), diff --git a/tests/palette.js b/tests/palette.js index 1f4f6fb4..79dab48d 100644 --- a/tests/palette.js +++ b/tests/palette.js @@ -151,7 +151,7 @@ const blockError = (original, result) => { // an image with a gain map keeps a valid one { const hdr = new Image(8, 8).fill((x, y) => Colors.fromRGBA(x * 30, y * 30, 200, 255)); - hdr.gainMap = new GainMap(new Image(4, 4).fill(0x808080ff), {gainMapMin: 0, gainMapMax: 2}); + hdr.gainMap = new GainMap(new Image(4, 4).fill(0x808080ff), {min: 0, max: 2}); hdr.quantize({colors: 4, dither: 'floyd-steinberg'}); if (!(hdr.gainMap instanceof GainMap)) panic('gain map removed by quantize'); if (colorsOf(hdr).size > 4) panic('quantize with a gain map'); diff --git a/tests/regressions.js b/tests/regressions.js index f93cd82b..384a8701 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -544,7 +544,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); return out; }; const mapped = new Image(8, 8).fill(0x808080ff); - mapped.gainMap = new GainMap(new Image(4, 4).fill(0xc0c0c0ff), {gainMapMin: 0, gainMapMax: 2}); + mapped.gainMap = new GainMap(new Image(4, 4).fill(0xc0c0c0ff), {min: 0, max: 2}); const jhgm = boxes(Buffer.from(await mapped.encodeJXL())).find(b => b.type === 'jhgm').box; const codestream = fs.readFileSync('./tests/jxl/animation.jxl'); const container = Buffer.concat([box('JXL ', Buffer.from([0x0d, 0x0a, 0x87, 0x0a])), box('ftyp', Buffer.from('jxl \0\0\0\0jxl ', 'latin1')), jhgm, box('jxlc', codestream)]); @@ -552,7 +552,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const withMap = await Animation.decode(container); assert(plain.length > 1 && withMap.length === plain.length, 'JPEG XL animation with a jhgm box decodes'); assert(plain.frames.every(f => f.gainMap === null) && (await Animation.decode(codestream)).frames.every(f => f.gainMap === null), 'JPEG XL: gainMap false, or no jhgm box'); - assert(withMap.frames.every(f => f.gainMap && f.gainMap.width === 4 && f.gainMap.gainMapMax[0] === 2), 'JPEG XL: every frame has the gain map'); + assert(withMap.frames.every(f => f.gainMap && f.gainMap.width === 4 && f.gainMap.max[0] === 2), 'JPEG XL: every frame has the gain map'); assert(withMap.frames[0].gainMap !== withMap.frames[1].gainMap && withMap.frames.every((f, i) => Buffer.from(f.bitmap).equals(Buffer.from(plain.frames[i].bitmap))), 'JPEG XL: the frames are the same with the map, with maps of their own'); // toneMapping is checked @@ -571,7 +571,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const frame = () => Frame.from(bordered(), {duration: 100, xOffset: 3, yOffset: 4}); assert(frame().trim({color: 0x102030ff}).xOffset === 5, 'Frame.trim({color}) moves the offsets'); assert(same(bordered().colorToAlpha(), bordered().colorToAlpha(undefined)) && same(bordered().colorToAlpha(), bordered().colorToAlpha({})), 'colorToAlpha() without arguments'); - const map = new GainMap(new Image(4, 4).fill(0x808080ff), {gainMapMin: 0, gainMapMax: 2}); + const map = new GainMap(new Image(4, 4).fill(0x808080ff), {min: 0, max: 2}); assert(Buffer.from(await map.encode()).equals(Buffer.from(await map.encode({}))), 'GainMap.encode() without arguments'); } @@ -634,7 +634,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); throws(() => shape.clone().recolor(new Image(3, 1)), RangeError, 'recolor(image) with another size'); assert(shape.clone().recolor(0x336699ff).bitmap[0] === 0x33, 'recolor(color) still works'); const mapped = new Image(8, 8).fill(0xffffffff); - mapped.gainMap = new GainMap(new Image(4, 4).fill(0x808080ff), {gainMapMin: 0, gainMapMax: 2}); + mapped.gainMap = new GainMap(new Image(4, 4).fill(0x808080ff), {min: 0, max: 2}); mapped.recolor(Image.linearGradient(8, 8, {stops: [0xff0000ff, 0x0000ffff], angle: 90})); assert(px(mapped, 0, 0)[0] > 200 && px(mapped, 7, 0)[2] > 200 && mapped.gainMap && mapped.gainMap.width === 4, 'recolor(image) keeps the gain map'); @@ -675,7 +675,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // gain maps grow with the image const hdr = square(); - hdr.gainMap = new GainMap(new Image(5, 5).fill(0xc0c0c0ff), {gainMapMin: 0, gainMapMax: 2}); + hdr.gainMap = new GainMap(new Image(5, 5).fill(0xc0c0c0ff), {min: 0, max: 2}); const hdrShadow = hdr.clone().dropShadow({x: 5, y: 0, blur: 1}); assert(hdrShadow.gainMap && Math.abs(hdrShadow.gainMap.width / hdrShadow.width - 0.5) < 0.1 && Math.abs(hdrShadow.gainMap.height / hdrShadow.height - 0.5) < 0.1, 'dropShadow keeps the gain map at its scale'); diff --git a/tests/transform.js b/tests/transform.js index 9f3ff0c9..2678dfdc 100644 --- a/tests/transform.js +++ b/tests/transform.js @@ -114,7 +114,7 @@ const photo = (width, height) => new Image(width, height).fill((x, y) => Colors. // gain maps move with the image const hdr = photo(20, 10); - hdr.gainMap = new GainMap(new Image(10, 5).fill((x) => x < 5 ? 0x000000ff : 0xffffffff), {gainMapMin: 0, gainMapMax: 2}); + hdr.gainMap = new GainMap(new Image(10, 5).fill((x) => x < 5 ? 0x000000ff : 0xffffffff), {min: 0, max: 2}); const flipped = hdr.clone().transform([-1, 0, 0, 1, 0, 0]); check(flipped.gainMap && flipped.gainMap.width === 10 && flipped.gainMap.height === 5, 'transform keeps the gain map'); check(px(flipped.gainMap.image, 0, 2)[0] > 200 && px(flipped.gainMap.image, 9, 2)[0] < 60, 'transform: the gain map is flipped with the image'); From 12a854e782bd1de9007a7bfc656b9e69a9604764 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:33:06 +0000 Subject: [PATCH 047/188] check frame durations and offsets when set --- src/frame.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/src/frame.ts b/src/frame.ts index 0d4e771a..df48a994 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -43,15 +43,12 @@ export interface FrameOptions { * method of {@link Image} works on it too */ export class Frame extends Image { - /** - * Milliseconds. GIFs store hundredths of a second, so this is rounded to 10 ms when encoding a GIF; - * note that browsers play frames shorter than 20 ms with 100 ms - */ - duration: number; - /** The frame's horizontal offset in the animation */ - xOffset: number; - /** The frame's vertical offset in the animation */ - yOffset: number; + /** @internal @ignore */ + private __duration__ = 100; + /** @internal @ignore */ + private __x__ = 0; + /** @internal @ignore */ + private __y__ = 0; /** * @internal * @ignore @@ -92,6 +89,51 @@ export class Frame extends Image { this.disposalMode = disposal; } + /** + * The frame's duration in milliseconds (a finite number from 0, else it throws a RangeError). GIFs store + * hundredths of a second, so this is rounded to 10 ms when encoding a GIF; note that browsers play frames shorter + * than 20 ms with 100 ms + */ + get duration(): number { + return this.__duration__; + } + + /** + * Sets the duration + * @param duration Milliseconds, a finite number from 0 + */ + set duration(duration: number) { + const value = finite(duration, 'frame duration'); + if (value < 0) throw new RangeError('Invalid frame duration'); + this.__duration__ = value; + } + + /** The frame's horizontal offset in the animation (a finite number, else it throws a RangeError) */ + get xOffset(): number { + return this.__x__; + } + + /** + * Sets the horizontal offset + * @param x The offset in pixels + */ + set xOffset(x: number) { + this.__x__ = finite(x, 'x offset'); + } + + /** The frame's vertical offset in the animation (a finite number, else it throws a RangeError) */ + get yOffset(): number { + return this.__y__; + } + + /** + * Sets the vertical offset + * @param y The offset in pixels + */ + set yOffset(y: number) { + this.__y__ = finite(y, 'y offset'); + } + /** The frame's disposal mode (`'any'`, `'keep'`, `'previous'` or `'background'`) */ get disposalMode(): FrameDisposalModeName { return DISPOSAL_MODES[this.__disposalMode__]; From e48594f526a53f5e04273ea133fa4f5495744089 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:33:06 +0000 Subject: [PATCH 048/188] let contain pad to the box and add the inside fit of resize --- src/frame.ts | 4 +- src/image.ts | 86 ++++++++++++++++++++++++++----------------- tests/options-only.js | 8 ++-- tests/regressions.js | 4 +- 4 files changed, 61 insertions(+), 41 deletions(-) diff --git a/src/frame.ts b/src/frame.ts index df48a994..6f6a9a6f 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -232,11 +232,11 @@ export class Frame extends Image { */ override resize(options: ResizeToOptions): this; override resize(widthOrOptions: number | ResizeToOptions, heightArgument?: number, optionsArgument?: ResizeOptions): this { - const [width, height, options] = resizeArguments(widthOrOptions, heightArgument, optionsArgument); + const [width, height, options, fit] = resizeArguments(widthOrOptions, heightArgument, optionsArgument); const originalWidth = this.width; const originalHeight = this.height; - super.resize(...this.__resized_size__(width, height), options); + super.resize(...this.__resized_size__(width, height, fit), options); this.xOffset = Math.round(this.xOffset * this.width / originalWidth); this.yOffset = Math.round(this.yOffset * this.height / originalHeight); diff --git a/src/image.ts b/src/image.ts index 4a737644..2864d6e0 100644 --- a/src/image.ts +++ b/src/image.ts @@ -103,9 +103,22 @@ export interface ResizeToOptions extends ResizeOptions { width?: number; /** The new height. Without it, it follows from the width */ height?: number; + /** + * How a width and a height together are met: `'fill'` stretches the image to exactly that size, `'inside'` keeps + * the aspect ratio and makes the image as large as fits inside it, so one side can be smaller (a thumbnail of at + * most 800x600: `resize({ width: 800, height: 600, fit: 'inside' })`). For a box of exactly that size, see + * {@link Image.contain} and {@link Image.cover}. Default: `'fill'` + */ + fit?: 'fill' | 'inside'; +} + +/** Options of {@link Image.contain} */ +export interface ContainOptions extends ResizeOptions { + /** The color of the bars around the image where it doesn't fill the box. Default: `0x00000000` (transparent) */ + background?: Color; } -/** Options of {@link Image.scale}, {@link Image.resize}, {@link Image.contain}, {@link Image.fit} and {@link Image.cover} */ +/** Options of {@link Image.scale}, {@link Image.resize}, {@link Image.contain} and {@link Image.cover} */ export interface ResizeOptions { /** The resizing mode. Default: {@link ResizeModes.BILINEAR} */ mode?: ResizeMode; @@ -945,45 +958,37 @@ export class Image { */ resize(options: ResizeToOptions): this; resize(widthOrOptions: number | ResizeToOptions, heightArgument?: number, optionsArgument?: ResizeOptions): this { - const [width, height, options] = resizeArguments(widthOrOptions, heightArgument, optionsArgument); + const [width, height, options, fit] = resizeArguments(widthOrOptions, heightArgument, optionsArgument); const { mode, fast } = resizeOptions(options); - const image = this.__resize__(...this.__resized_size__(width, height), mode, fast); + const image = this.__resize__(...this.__resized_size__(width, height, fit), mode, fast); this.__gain_map__?.__rescale__(this.width, this.height, image.width, image.height); return this.__apply__(image); } /** - * Resizes the image so it is contained in the given bounding box. - * Can return an image with one axis smaller than the given bounding box. - * @param width The width of the bounding box - * @param height The height of the bounding box - * @param options See {@link ResizeOptions} + * Resizes the image to fit inside the given box, keeping its aspect ratio, and centers it in a box of exactly that + * size (like CSS' `object-fit: contain`): the bars where it doesn't fill the box are transparent or the + * `background` color. To only resize it to fit inside the box, without the bars, use + * `resize({ width, height, fit: 'inside' })` + * @param width The width of the box + * @param height The height of the box + * @param options The background and how to resize, see {@link ContainOptions} * @returns The resized image */ - contain(width: number, height: number, options?: ResizeOptions): this; - contain(width: number, height: number, options?: ResizeOptions): this { - checkOptions(options, 'contain(width, height, { mode: \'area\' })'); - const resizeOpts = resizeOptions(options); + contain(width: number, height: number, options?: ContainOptions): this; + contain(width: number, height: number, options?: ContainOptions): this { + checkOptions(options, 'contain(width, height, { background: 0x000000ff })'); + const { background = 0x00000000, ...resizeOpts } = options ?? {}; + const backgroundColor = checkColor(background, 'background'); + resizeOptions(resizeOpts); width = Math.floor(finite(width, 'width')); height = Math.floor(finite(height, 'height')); - // round instead of flooring the scaled size, so the limiting axis matches the box exactly - const scale = Math.min(width / this.width, height / this.height); - return this.resize(Math.max(1, Math.min(width, Math.round(this.width * scale))), Math.max(1, Math.min(height, Math.round(this.height * scale))), resizeOpts); - } - - /** - * Resizes the image so it is contained in the given bounding box, placing it in the center of the given bounding box. - * Always returns the exact dimensions of the bounding box. - * @param width The width of the bounding box - * @param height The height of the bounding box - * @param options See {@link ResizeOptions} - * @returns The resized image - */ - fit(width: number, height: number, options?: ResizeOptions): this; - fit(width: number, height: number, options?: ResizeOptions): this { - checkOptions(options, 'fit(width, height, { mode: \'area\' })'); + // (checked before anything changes) + if (width < 1) throw new RangeError('Image has to be at least 1 pixel wide'); + if (height < 1) throw new RangeError('Image has to be at least 1 pixel high'); const result = new Image(width, height); - this.contain(width, height, options); + if (backgroundColor !== 0) result.fill(backgroundColor); + this.resize(...insideSize(this.width, this.height, width, height), resizeOpts); const x = (width - this.width) / 2 | 0, y = (height - this.height) / 2 | 0; result.composite(this, { x, y }); this.__gain_map__?.__transform__(this.width, this.height, result.width, result.height, (X, Y) => [X - x, Y - y]); @@ -1028,7 +1033,8 @@ export class Image { * @internal * @ignore */ - protected __resized_size__(width: number | undefined, height: number | undefined): [width: number, height: number] { + protected __resized_size__(width: number | undefined, height: number | undefined, fit: 'fill' | 'inside' = 'fill'): [width: number, height: number] { + if (fit === 'inside' && width !== undefined && height !== undefined) return insideSize(this.width, this.height, width, height); // a side left out follows the other one, keeping the aspect ratio if (width === undefined) return [Math.max(1, Math.round(this.width / this.height * Math.floor(height as number))), height as number]; if (height === undefined) return [width, Math.max(1, Math.round(this.height / this.width * Math.floor(width)))]; @@ -3035,16 +3041,28 @@ interface HSLA { * The width, height and mode of resize()'s signatures: a side left out of the options is `undefined` * @internal */ -export function resizeArguments(widthOrOptions: number | ResizeToOptions, height?: number, options?: ResizeOptions): [width: number | undefined, height: number | undefined, options: ResizeOptions | undefined] { +export function resizeArguments(widthOrOptions: number | ResizeToOptions, height?: number, options?: ResizeOptions): [width: number | undefined, height: number | undefined, options: ResizeOptions | undefined, fit: 'fill' | 'inside'] { if (!isOptions(widthOrOptions)) { checkOptions(options, 'resize(width, height, { mode: \'area\' })'); // without a height, the aspect ratio would be kept silently (3.x refused that call) if (height === undefined) throw new TypeError(`resize() needs a height or the options form, like resize({ width: ${widthOrOptions} }) to keep the aspect ratio`); - return [finite(widthOrOptions, 'width'), finite(height, 'height'), options]; + return [finite(widthOrOptions, 'width'), finite(height, 'height'), options, 'fill']; } - const { width, height: optionsHeight, ...rest } = widthOrOptions; + const { width, height: optionsHeight, fit = 'fill', ...rest } = widthOrOptions; if (width === undefined && optionsHeight === undefined) throw new TypeError('resize() needs a width, a height or both'); - return [width, optionsHeight, rest]; + if (fit !== 'fill' && fit !== 'inside') throw new TypeError(`fit must be 'fill' or 'inside', not ${String(fit)}`); + return [width === undefined ? undefined : finite(width, 'width'), optionsHeight === undefined ? undefined : finite(optionsHeight, 'height'), rest, fit]; +} + +/** + * The size of an image of `width` x `height` resized to fit inside a box of `boxWidth` x `boxHeight`, keeping its aspect + * ratio: the limiting side matches the box exactly (rounded instead of floored), the other one is at least 1 pixel + */ +export function insideSize(width: number, height: number, boxWidth: number, boxHeight: number): [width: number, height: number] { + boxWidth = Math.floor(boxWidth); + boxHeight = Math.floor(boxHeight); + const scale = Math.min(boxWidth / width, boxHeight / height); + return [Math.max(1, Math.min(boxWidth, Math.round(width * scale))), Math.max(1, Math.min(boxHeight, Math.round(height * scale)))]; } function resizeOptions(options?: ResizeOptions): { mode: ResizeMode, fast: boolean } { diff --git a/tests/options-only.js b/tests/options-only.js index 61ea5b84..3c070cb8 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -21,10 +21,10 @@ const sameImage = (image, reference, label) => { (async () => { const photo = new Image(20, 12).fill((x, y) => 0x336699ff + x * 0x01000000 + y * 0x00010000 >>> 0); - // resize, scale, contain, fit and cover take the resizing mode in the options only + // resize, scale, contain and cover take the resizing mode in the options only const modes = {'a mode name': ResizeModes.AREA, 'a string': 'bilinear', 'an unknown mode': 'Area', 'null': null}; for (const [label, mode] of Object.entries(modes)) { - for (const method of ['resize', 'contain', 'fit', 'cover']) { + for (const method of ['resize', 'contain', 'cover']) { const image = photo.clone(); const error = await rejects(() => image[method](10, 6, mode), `${method}(10, 6, ${label})`); if (!/takes an options object/.test(error.message)) panic(`${method}(10, 6, ${label}): the message should name the options form, got ${error.message}`); @@ -164,8 +164,8 @@ const sameImage = (image, reference, label) => { if (photo.clone().resize(10, 6).height !== 6) panic('resize(10, 6)'); if (photo.clone().resize(10, 6, undefined).height !== 6) panic('resize(10, 6, undefined)'); if (photo.clone().scale(0.5, {mode: ResizeModes.AREA}).width !== 10) panic('scale(0.5, { mode })'); - if (photo.clone().contain(10, 10, {mode: ResizeModes.AREA}).width !== 10) panic('contain(10, 10, { mode })'); - if (photo.clone().fit(10, 10, {mode: ResizeModes.AREA}).height !== 10) panic('fit(10, 10, { mode })'); + if (photo.clone().resize({width: 10, height: 10, fit: 'inside', mode: ResizeModes.AREA}).width !== 10) panic('resize({ width, height, fit: \'inside\', mode })'); + if (photo.clone().contain(10, 10, {mode: ResizeModes.AREA}).height !== 10) panic('contain(10, 10, { mode })'); if (photo.clone().cover(10, 10, {mode: ResizeModes.AREA}).width !== 10) panic('cover(10, 10, { mode })'); if (new Frame(20, 12).resize(10, 6, {mode: ResizeModes.AREA}).width !== 10) panic('Frame.resize(10, 6, { mode })'); if (new Animation([new Frame(20, 12)]).resize(10, 6, {mode: ResizeModes.AREA}).frames[0].width !== 10) panic('Animation.resize(10, 6, { mode })'); diff --git a/tests/regressions.js b/tests/regressions.js index 384a8701..187d3eec 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -182,7 +182,9 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const covered = new Image(width, 61).cover(target, 33); assert(covered.width === target && covered.height === 33, `cover(${target}, 33) of ${width}x61 returned ${covered}`); const contained = new Image(width, 61).contain(target, 33); - assert((contained.width === target || contained.height === 33) && contained.width <= target && contained.height <= 33, `contain(${target}, 33) of ${width}x61 returned ${contained}`); + assert(contained.width === target && contained.height === 33, `contain(${target}, 33) of ${width}x61 returned ${contained}`); + const inside = new Image(width, 61).resize({width: target, height: 33, fit: 'inside'}); + assert((inside.width === target || inside.height === 33) && inside.width <= target && inside.height <= 33, `resize({ width: ${target}, height: 33, fit: 'inside' }) of ${width}x61 returned ${inside}`); } } } From 774d987bc5973dfec9f0a5890b4fcb1e47609582 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:33:06 +0000 Subject: [PATCH 049/188] give animations a canvas and add clone, crop, contain and cover --- src/animation.ts | 167 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 146 insertions(+), 21 deletions(-) diff --git a/src/animation.ts b/src/animation.ts index 78d0f7f0..cec048e7 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -6,12 +6,12 @@ import * as jxl from './codecs/jxl.ts'; import { availableThreads } from './util/threads.ts'; import { BinaryData, view } from './util/mem.ts'; import { GainMap } from './gainmap.ts'; -import { Image, DecodeOptions, ResizeOptions, ResizeToOptions, resizeArguments } from './image.ts'; +import { Image, DecodeOptions, ContainOptions, ResizeOptions, ResizeToOptions, resizeArguments, insideSize } from './image.ts'; import { Frame, FrameDisposalModes } from './frame.ts'; import { ImageType } from './image-type.ts'; import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts'; import { Metadata, resolveMetadata, pngText, exifData, gifComments } from './metadata.ts'; -import { checkOptions, finite, compressionLevel, loopCount, toPlays, fromPlays, fitLoops } from './util/options.ts'; +import { checkOptions, finite, compressionLevel, loopCount, toPlays, fromPlays, fitLoops, color as checkColor } from './util/options.ts'; import { MAX_PIXELS, decoding } from './util/limits.ts'; import { AnimationEncoder } from './animation-encoder.ts'; @@ -97,18 +97,24 @@ export class Animation { /** @internal @ignore */ private __frames__: Frame[] = []; + /** @internal @ignore the canvas size, once set (else the frames' extent) */ + private __canvas__: [width: number, height: number] | null = null; + /** * Creates a new animation * @param frames The frames to create the animation from - * @param options See {@link AnimationOptions} + * @param options The canvas size and the loop count, see {@link AnimationCanvasOptions} */ - constructor(frames: Frame[], options?: AnimationOptions); - constructor(frames: Frame[], options: AnimationOptions = {}) { + constructor(frames: Frame[], options?: AnimationCanvasOptions); + constructor(frames: Frame[], options: AnimationCanvasOptions = {}) { checkOptions(options, 'Animation(frames, { loopCount: 2 })'); - const { loopCount: count = Infinity } = options; + const { loopCount: count = Infinity, width, height } = options; const loops = loopCount(count); + if ((width === undefined) !== (height === undefined)) throw new TypeError('Animation() takes both a canvas width and height, or neither'); + const canvas = width === undefined ? null : canvasSize(width, height as number); this.frames = frames; this.__loopCount__ = loops; + this.__canvas__ = canvas; } /** @@ -160,20 +166,32 @@ export class Animation { return this.__frames__[Symbol.iterator](); } - /** The animation's width */ + /** + * The canvas width: the one given to the constructor, the decoded file's, or the one {@link Animation.resize}, + * {@link Animation.crop}, {@link Animation.contain} and {@link Animation.cover} set; else the frames' right edge + */ get width(): number { + if (this.__canvas__) return this.__canvas__[0]; let max = 0; for (const frame of this.frames) max = Math.max(max, frame.width + frame.xOffset); return max; } - /** The animation's height */ + /** The canvas height, like {@link Animation.width}; else the frames' bottom edge */ get height(): number { + if (this.__canvas__) return this.__canvas__[1]; let max = 0; for (const frame of this.frames) max = Math.max(max, frame.height + frame.yOffset); return max; } + /** Clones the animation: its frames (with their pixels), canvas size and loop count */ + clone(): Animation { + const copy = new Animation(this.frames.map(frame => frame.clone()), { loopCount: this.loopCount }); + copy.__canvas__ = this.__canvas__ && [...this.__canvas__]; + return copy; + } + /** The animation's duration (in ms) */ get duration(): number { let duration = 0; @@ -201,11 +219,14 @@ export class Animation { */ resize(options: ResizeToOptions): this; resize(widthOrOptions: number | ResizeToOptions, heightArgument?: number, optionsArgument?: ResizeOptions): this { - const [givenWidth, givenHeight, options] = resizeArguments(widthOrOptions, heightArgument, optionsArgument); + const [givenWidth, givenHeight, options, fit] = resizeArguments(widthOrOptions, heightArgument, optionsArgument); const originalWidth = this.width, originalHeight = this.height; + if (!originalWidth || !originalHeight) throw new RangeError('An animation without frames or canvas can\'t be resized'); // a side left out follows the other one, keeping the aspect ratio - const width = givenWidth ?? originalWidth / originalHeight * (givenHeight as number); - const height = givenHeight ?? originalHeight / originalWidth * width; + let width = givenWidth ?? originalWidth / originalHeight * (givenHeight as number); + let height = givenHeight ?? originalHeight / originalWidth * width; + if (fit === 'inside' && givenWidth !== undefined && givenHeight !== undefined) [width, height] = insideSize(originalWidth, originalHeight, givenWidth, givenHeight); + const canvas = canvasSize(Math.max(1, Math.round(width)), Math.max(1, Math.round(height))); const sx = width / originalWidth, sy = height / originalHeight; for (const frame of this.frames) { @@ -217,10 +238,104 @@ export class Animation { frame.xOffset = left; frame.yOffset = top; } + this.__canvas__ = canvas; + + return this; + } + + /** + * Crops the canvas to the given region: the frames move with it and are cut to the new canvas. The region is + * clipped to the canvas, so the result can be smaller than the requested size + * @param x The x offset (0-based) + * @param y The y offset (0-based) + * @param width The new canvas width + * @param height The new canvas height + */ + crop(x: number, y: number, width: number, height: number): this { + x = Math.max(0, Math.floor(finite(x, 'x'))); + y = Math.max(0, Math.floor(finite(y, 'y'))); + width = Math.min(Math.floor(finite(width, 'width')), this.width - x); + height = Math.min(Math.floor(finite(height, 'height')), this.height - y); + if (!(width >= 1 && height >= 1)) throw new RangeError('The crop region has to overlap the canvas'); + for (const frame of this.frames) { + frame.xOffset -= x; + frame.yOffset -= y; + } + this.__canvas__ = [width, height]; + this.frames = this.__clipped_frames__(); + return this; + } + /** + * Resizes the animation to fit inside the given box, keeping its aspect ratio, and centers it on a canvas of + * exactly that size, like {@link Image.contain}. With a `background` color, the frames become full canvas frames + * (the composited animation) on that color + * @param width The width of the box + * @param height The height of the box + * @param options The background and how to resize, see {@link ContainOptions} + */ + contain(width: number, height: number, options?: ContainOptions): this; + contain(width: number, height: number, options?: ContainOptions): this { + checkOptions(options, 'contain(width, height, { background: 0x000000ff })'); + const { background = 0x00000000, ...resizeOpts } = options ?? {}; + const backgroundColor = checkColor(background, 'background'); + const [boxWidth, boxHeight] = canvasSize(Math.floor(finite(width, 'width')), Math.floor(finite(height, 'height'))); + this.resize({ width: boxWidth, height: boxHeight, fit: 'inside', ...resizeOpts }); + const x = (boxWidth - this.width) / 2 | 0, y = (boxHeight - this.height) / 2 | 0; + for (const frame of this.frames) { + frame.xOffset += x; + frame.yOffset += y; + } + this.__canvas__ = [boxWidth, boxHeight]; + if (backgroundColor !== 0) { + const composited = png.compositeAnimation({ width: boxWidth, height: boxHeight, frames: this.__apngFrames__() }); + this.frames = composited.map(({ pixels, duration }) => { + const frame = new Frame(boxWidth, boxHeight, { duration, disposalMode: FrameDisposalModes.BACKGROUND }).fill(backgroundColor); + const content = new Image(boxWidth, boxHeight); + content.bitmap = pixels; + return frame.composite(content); + }); + } return this; } + /** + * Resizes the animation to cover the given box, keeping its aspect ratio, and crops the overflowing edges, like + * {@link Image.cover}: the canvas gets exactly that size + * @param width The width of the box + * @param height The height of the box + * @param options See {@link ResizeOptions} + */ + cover(width: number, height: number, options?: ResizeOptions): this; + cover(width: number, height: number, options?: ResizeOptions): this { + checkOptions(options, 'cover(width, height, { mode: \'area\' })'); + const [boxWidth, boxHeight] = canvasSize(Math.floor(finite(width, 'width')), Math.floor(finite(height, 'height'))); + const scale = Math.max(boxWidth / this.width, boxHeight / this.height); + this.resize(Math.max(boxWidth, Math.round(this.width * scale)), Math.max(boxHeight, Math.round(this.height * scale)), options); + return this.crop((this.width - boxWidth) / 2, (this.height - boxHeight) / 2, boxWidth, boxHeight); + } + + /** + * The frames cut to the canvas: frames that lie inside it as they are, the others cropped (copies), and frames + * entirely outside as a transparent pixel (they show nothing, their duration counts) + * @internal + * @ignore + */ + __clipped_frames__(): Frame[] { + const width = this.width, height = this.height; + return this.frames.map(frame => { + const { xOffset: x, yOffset: y } = frame; + if (x >= 0 && y >= 0 && x + frame.width <= width && y + frame.height <= height) return frame; + const left = Math.max(0, x), top = Math.max(0, y); + const right = Math.min(width, x + frame.width), bottom = Math.min(height, y + frame.height); + const options = { duration: frame.duration, disposalMode: frame.disposalMode }; + if (right <= left || bottom <= top) return new Frame(1, 1, { ...options, disposalMode: 'keep' }); + const clipped = Frame.from(frame.region(left - x, top - y, right - left, bottom - top), { ...options, xOffset: left, yOffset: top }); + clipped.gainMap = null; + return clipped; + }); + } + /** * @internal * @ignore @@ -245,7 +360,7 @@ export class Animation { this.__validate__(); const frames = optimize ? this.__optimized__({ replace: false, even: false, alpha: 'frame', palette: 256, cover: true }).map(f => ({ ...f, dispose: f.dispose ? 3 : 1 })) - : Array.from(this.frames, frame => ({ + : Array.from(this.__clipped_frames__(), frame => ({ x: frame.xOffset, y: frame.yOffset, width: frame.width, @@ -307,12 +422,13 @@ export class Animation { __webpFrames__(): webp.WebPEncodeFrame[] { const width = this.width, height = this.height; // WebP knows no "previous" disposal and stores offsets halved: such animations are stored as full frames - const fullFrames = this.frames.some(f => f.disposalMode === 'previous' || (f.disposalMode === 'background' && (f.xOffset & 1 || f.yOffset & 1))); + const clipped = this.__clipped_frames__(); + const fullFrames = clipped.some(f => f.disposalMode === 'previous' || (f.disposalMode === 'background' && (f.xOffset & 1 || f.yOffset & 1))); if (fullFrames) return png.compositeAnimation({ width, height, frames: this.__apngFrames__() }) .map(c => ({ x: 0, y: 0, width, height, duration: c.duration, dispose: true, pixels: c.pixels })); - return Array.from(this.frames, frame => { + return Array.from(clipped, frame => { let { xOffset: x, yOffset: y, width: w, height: h } = frame; let pixels = new Uint8Array(frame.bitmap.buffer, frame.bitmap.byteOffset, frame.bitmap.byteLength); // odd offsets: one transparent pixel more on the left/top (blending makes it invisible) @@ -343,7 +459,7 @@ export class Animation { */ __apngFrames__(): png.APNGFrame[] { const dispose = [0, 0, 2, 1]; - return Array.from(this.frames, frame => ({ + return Array.from(this.__clipped_frames__(), frame => ({ x: frame.xOffset, y: frame.yOffset, width: frame.width, @@ -433,18 +549,18 @@ export class Animation { if (type === 'webp') { const decoded = webp.decodeAnimation(data, false, maxPixels); const frames = orient(profile(canvasFrames(decoded.width, decoded.height, webp.composite(decoded)), decoded.icc), decoded.orientation); - return new Animation(frames, { loopCount: fromPlays(decoded.loopCount) }); + return new Animation(frames, { loopCount: fromPlays(decoded.loopCount), width: frames[0]?.width ?? decoded.width, height: frames[0]?.height ?? decoded.height }); } // (still PNGs decode like images) if (type === 'png' && !png.isAnimated(data)) { const image = await Image.decode(data, { maxPixels, applyOrientation, applyColorProfile }); - return new Animation(canvasFrames(image.width, image.height, [{ pixels: image.bitmap, duration: 0 }]), { loopCount: Infinity }); + return new Animation(canvasFrames(image.width, image.height, [{ pixels: image.bitmap, duration: 0 }]), { loopCount: Infinity, width: image.width, height: image.height }); } if (type === 'png') { const decoded = await png.decodeAnimation(data, false, maxPixels); const frames = orient(profile(canvasFrames(decoded.width, decoded.height, png.compositeAnimation(decoded)), decoded.icc), decoded.orientation); - return new Animation(frames, { loopCount: decoded.frames.length > 1 ? fromPlays(decoded.plays) : Infinity }); + return new Animation(frames, { loopCount: decoded.frames.length > 1 ? fromPlays(decoded.plays) : Infinity, width: frames[0]?.width ?? decoded.width, height: frames[0]?.height ?? decoded.height }); } if (type === 'jxl') { @@ -476,10 +592,13 @@ export class Animation { frame.gainMap = null; } } - return new Animation(frames, { loopCount: frames.length > 1 ? loops : Infinity }); + return new Animation(frames, { loopCount: frames.length > 1 ? loops : Infinity, ...(frames.length ? { width: frames[0].width, height: frames[0].height } : {}) }); } - if (type) return new Animation([Frame.from(await Image.decode(data, { maxPixels, applyOrientation, applyColorProfile, toneMapping, gainMap, threads }), { duration: 0 })]); + if (type) { + const image = await Image.decode(data, { maxPixels, applyOrientation, applyColorProfile, toneMapping, gainMap, threads }); + return new Animation([Frame.from(image, { duration: 0 })], { width: image.width, height: image.height }); + } throw new Error('Unsupported image type'); } @@ -490,6 +609,12 @@ export class Animation { private static __gifFrames__(data: Uint8Array, maxPixels: number): Animation { const decoded = gifCodec.decode(data, false, maxPixels); const frames = gifCodec.composite(decoded).map(({ pixels, delay }) => ({ pixels, duration: 10 * delay })); - return new Animation(canvasFrames(decoded.width, decoded.height, frames), { loopCount: decoded.loopCount }); + return new Animation(canvasFrames(decoded.width, decoded.height, frames), { loopCount: decoded.loopCount, width: decoded.width, height: decoded.height }); } } + +// a canvas size: positive integers +function canvasSize(width: number, height: number): [width: number, height: number] { + if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) throw new RangeError(`Invalid canvas size: ${width}x${height}`); + return [width, height]; +} From 3fa8d0afd98c465901a5d720da610f01bd14aa90 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:33:07 +0000 Subject: [PATCH 050/188] remove drawBox and drawCircle --- src/image.ts | 107 ------------------------------------ tests/animation-optimize.js | 16 +++--- tests/animation.js | 4 +- tests/circle.js | 26 --------- tests/gain-redraw.js | 16 +++--- tests/gainmap.js | 4 +- tests/misc.js | 10 ---- tests/options-only.js | 2 +- tests/readme.js | 4 +- tests/regressions.js | 49 ++++------------- tests/targets/readme.png | Bin 191140 -> 148944 bytes tests/trim.js | 6 +- tests/webp-lossy.js | 2 +- 13 files changed, 39 insertions(+), 207 deletions(-) diff --git a/src/image.ts b/src/image.ts index 2864d6e0..ad005d1a 100644 --- a/src/image.ts +++ b/src/image.ts @@ -1223,113 +1223,6 @@ export class Image { return image; } - /** - * Draws a box at the specified coordinates (0-based), clipped to the image - * @param x The x offset - * @param y The y offset - * @param width The box width - * @param height The box height - * @param color The color to fill the box in with, a function gets coordinates relative to the box - */ - drawBox(x: number, y: number, width: number, height: number, color: Color | ColorFunction): this { - x = ~~finite(x, 'x'); - y = ~~finite(y, 'y'); - width = ~~finite(width, 'width'); - height = ~~finite(height, 'height'); - - if (typeof color !== 'function') this.__fast_box__(x, y, width, height, checkColor(color)); - else for (let tY = Math.max(0, -y); tY < height && tY + y < this.height; tY++) - for (let tX = Math.max(0, -x); tX < width && tX + x < this.width; tX++) - this.__set_pixel__(tX + x, tY + y, color(tX, tY)); - - // (the box's pixels have no HDR light beyond their own) - if (this.__gain_map__) gain.redraw(this, this.__gain_map__, Math.max(0, x), Math.max(0, y), Math.min(this.width, x + width), Math.min(this.height, y + height), (_x, _y, S, _boost, out) => { - out[0] = S[0]; - out[1] = S[1]; - out[2] = S[2]; - }); - return this; - } - - /** - * @internal - * @ignore - */ - protected __fast_box__(x: number, y: number, width: number, height: number, color: Color): this { - const left = Math.max(0, x), top = Math.max(0, y); - const right = Math.min(this.width, x + width), bottom = Math.min(this.height, y + height); - if (right <= left || bottom <= top) return this; - - // fill the first row, then copy it to the others - for (let xPos = left; xPos < right; xPos++) - this.__view__.setUint32(4 * (xPos + top * this.width), color); - - const start = 4 * (left + top * this.width); - const end = 4 * (right + top * this.width); - for (let row = top + 1; row < bottom; row++) - this.bitmap.copyWithin(4 * (left + row * this.width), start, end); - - return this; - } - - /** - * Draws an antialiased circle around the center of the specified pixel (0-based) - * @param x The center x position - * @param y The center y position - * @param radius The circles radius in pixels - * @param color The color, a function gets coordinates relative to the circle's bounding box - */ - drawCircle(x: number, y: number, radius: number, color: Color | ColorFunction): this { - x = ~~finite(x, 'x'); - y = ~~finite(y, 'y'); - radius = finite(radius, 'radius'); - if (typeof color !== 'function') color = checkColor(color); - // the circle is centered on the pixel's center, its edge is antialiased - const cx = x + 0.5, cy = y + 0.5; - - for (let py = Math.max(0, Math.floor(cy - radius)); py < Math.min(this.height, Math.ceil(cy + radius)); py++) { - for (let px = Math.max(0, Math.floor(cx - radius)); px < Math.min(this.width, Math.ceil(cx + radius)); px++) { - const coverage = circleCoverage(px, py, cx, cy, radius); - if (coverage <= 0) continue; - - const value = typeof color === 'function' ? color(px - x + radius, py - y + radius) : color; - if (coverage >= 1) this.__set_pixel__(px, py, value); - else this.__blend_pixel__(px, py, value, coverage); - } - } - - // (the circle's pixels have no HDR light beyond their own; at the antialiased edge in part, by the coverage) - const map = this.__gain_map__; - if (map) { - const oS = map.offsetSDR, oH = map.offsetHDR; - gain.redraw(this, map, Math.max(0, Math.floor(cx - radius)), Math.max(0, Math.floor(cy - radius)), Math.min(this.width, Math.ceil(cx + radius)), Math.min(this.height, Math.ceil(cy + radius)), (px, py, S, boost, out) => { - const coverage = Math.min(1, Math.max(0, circleCoverage(px, py, cx, cy, radius))); - for (let c = 0; c < 3; c++) out[c] = coverage * S[c] + (1 - coverage) * ((S[c] + oS[c]) * boost[c] - oH[c]); - }); - } - return this; - } - - /** - * Moves a pixel towards a color by the given amount (with premultiplied alpha) - * @internal - * @ignore - */ - protected __blend_pixel__(x: number, y: number, color: Color, amount: number): void { - const i = (y * this.width + x) * 4, b = this.bitmap; - const sa = (color & 0xff) / 255 * amount, da = b[i + 3] / 255 * (1 - amount); - const a = sa + da; - if (a <= 0) { - b[i] = b[i + 1] = b[i + 2] = b[i + 3] = 0; - return; - } - - b[i] = ((color >>> 24) * sa + b[i] * da) / a; - b[i + 1] = ((color >> 16 & 0xff) * sa + b[i + 1] * da) / a; - b[i + 2] = ((color >> 8 & 0xff) * sa + b[i + 2] * da) / a; - b[i + 3] = a * 255; - } - /** * Crops the image into a circle with an antialiased edge * @param options See {@link CropCircleOptions} diff --git a/tests/animation-optimize.js b/tests/animation-optimize.js index cd10dd3b..caa0b254 100644 --- a/tests/animation-optimize.js +++ b/tests/animation-optimize.js @@ -75,7 +75,7 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { // fading out: pixels get more transparent await check('fade out', new Animation(Array.from({length: 6}, (_, i) => - Frame.from(new Image(20, 20).drawBox(4, 4, 12, 12, 0x3366ff00 | 255 - i * 50), {duration: 100})))); + Frame.from(new Image(20, 20).composite(new Image(12, 12).fill(0x3366ff00 | 255 - i * 50), {x: 4, y: 4}), {duration: 100})))); // frames with offsets and disposal modes, odd offsets (WebP stores them halved) await check('offsets and disposal', new Animation([ @@ -89,9 +89,9 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { // a frame that only clears: GIF can't blend pixels to transparent, the frame before clears its area instead await check('cleared frame', new Animation([ - Frame.from(new Image(12, 12).drawBox(2, 2, 6, 6, 0xff0000ff), {duration: 100, xOffset: 0, yOffset: 0, disposalMode: 'background'}), + Frame.from(new Image(12, 12).composite(new Image(6, 6).fill(0xff0000ff), {x: 2, y: 2}), {duration: 100, xOffset: 0, yOffset: 0, disposalMode: 'background'}), new Frame(12, 12, {duration: 100}), - Frame.from(new Image(12, 12).drawBox(4, 4, 6, 6, 0x00ff00ff), {duration: 100}) + Frame.from(new Image(12, 12).composite(new Image(6, 6).fill(0x00ff00ff), {x: 4, y: 4}), {duration: 100}) ])); // an empty first frame, and identical frames, which become one with their durations added up (the empty last @@ -109,8 +109,8 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { { const background = new Image(40, 20).fill((x, y) => Colors.fromRGBA(y * 6, 100, 200 - y * 4, 255)); const animation = new Animation(Array.from({length: 6}, (_, i) => Frame.from(background.clone() - .drawBox(1, 1, 6, 6, Colors.fromRGBA(255, i * 40, 0, 255)) - .drawBox(32, 13, 6, 6, Colors.fromRGBA(0, 255, i * 40, 255)), {duration: 100}))); + .composite(new Image(6, 6).fill(Colors.fromRGBA(255, i * 40, 0, 255)), {x: 1, y: 1}) + .composite(new Image(6, 6).fill(Colors.fromRGBA(0, 255, i * 40, 255)), {x: 32, y: 13}), {duration: 100}))); await check('opaque animation', animation); // with transparency for free, the frames are sparse; with an alpha channel that costs as much as RGBA instead of RGB // (APNG), they're filled in @@ -153,7 +153,7 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { { const background = new Image(160, 120).fill((x, y) => Colors.fromRGBA(x * 7 ^ y * 13, x * y, y * 5, 255)); const animation = new Animation(Array.from({length: 20}, (_, i) => - Frame.from(background.clone().drawBox(10 + i * 6, 50, 8, 8, 0xffffffff), {duration: 50}))); + Frame.from(background.clone().composite(new Image(8, 8).fill(0xffffffff), {x: 10 + i * 6, y: 50}), {duration: 50}))); await check('screen recording', animation); const still = (await background.encodePNG()).length; for (const format of ['APNG', 'WebP', 'GIF']) { @@ -175,8 +175,8 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { }); const animation = new Animation(Array.from({length: 8}, (_, i) => { const layer = new Image(100, 60).fill(x => x < 80 ? Colors.fromRGBA(i % 4, 180 + i * 7 % 5, 250 + i * 3 % 4, 150) : 0); - return Frame.from(map.clone().composite(layer).drawBox(5 + i * 10, 20, 12, 12, 0xff0000ff) - .drawBox(90, 50, 6, 6, Colors.fromRGBA(0, i * 30, 0, 255)), {duration: 50}); + return Frame.from(map.clone().composite(layer).composite(new Image(12, 12).fill(0xff0000ff), {x: 5 + i * 10, y: 20}) + .composite(new Image(6, 6).fill(Colors.fromRGBA(0, i * 30, 0, 255)), {x: 90, y: 50}), {duration: 50}); })); const tolerance = 3; diff --git a/tests/animation.js b/tests/animation.js index 0a4909a9..8f1f3020 100644 --- a/tests/animation.js +++ b/tests/animation.js @@ -13,7 +13,9 @@ function expected(animation) { const previous = canvas.clone(); canvas.composite(frame, {x: frame.xOffset, y: frame.yOffset}); const shown = canvas.clone(); - if (frame.disposalMode === 'background') canvas.drawBox(frame.xOffset, frame.yOffset, frame.width, frame.height, 0); + if (frame.disposalMode === 'background') + for (let y = Math.max(0, frame.yOffset); y < Math.min(height, frame.yOffset + frame.height); y++) + for (let x = Math.max(0, frame.xOffset); x < Math.min(width, frame.xOffset + frame.width); x++) canvas.setPixelAt(x, y, 0); else if (frame.disposalMode === 'previous') canvas = previous; return shown; }); diff --git a/tests/circle.js b/tests/circle.js index 58069bc7..c8facf82 100644 --- a/tests/circle.js +++ b/tests/circle.js @@ -8,32 +8,6 @@ const panic = message => { }; (async () => { - { - const image = new Image(512, 512); - image.drawCircle(256, 256, 128, 0xffffffff); - - const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); - - if (process.env.OVERWRITE_TEST) - await fs.writeFile('./tests/targets/circle.png', encoded); - - const target = await fs.readFile('./tests/targets/circle.png'); - if (!await samePixels(target, encoded)) panic('drawCircle 128 failed'); - } - - { - const image = new Image(512, 512); - image.drawCircle(256, 256, 320, 0x000000ff); - - const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); - - if (process.env.OVERWRITE_TEST) - await fs.writeFile('./tests/targets/circle2.png', encoded); - - const target = await fs.readFile('./tests/targets/circle2.png'); - if (!await samePixels(target, encoded)) panic('drawCircle 320 failed'); - } - { const image = new Image(256, 512); image.fill(0x000000ff); diff --git a/tests/gain-redraw.js b/tests/gain-redraw.js index 5fbab8f5..39339805 100644 --- a/tests/gain-redraw.js +++ b/tests/gain-redraw.js @@ -25,24 +25,24 @@ const mapBytes = image => Buffer.from(image.gainMap.image.bitmap); for (let y = 0; y < map.height; y++) for (let x = 0; x < map.width; x++) { const i = 4 * (y * map.width + x), inside = x >= Math.floor(box.x * sx) && x < Math.ceil((box.x + box.width) * sx) && y >= Math.floor(box.y * sy) && y < Math.ceil((box.y + box.height) * sy); const differs = before[i] !== after[i] || before[i + 1] !== after[i + 1] || before[i + 2] !== after[i + 2]; - if (differs && !inside) panic(`drawBox: the map changed outside the box at ${x},${y}`); + if (differs && !inside) panic(`composite: the map changed outside the box at ${x},${y}`); if (differs) changed++; } - if (changed === 0) panic('drawBox: black over highlights should change the map'); + if (changed === 0) panic('composite: black over highlights should change the map'); // black has no highlight: no boost where it covers the map pixel completely const cx = Math.floor((box.x + box.width / 2) * sx), cy = Math.floor((box.y + box.height / 2) * sy); - if (Math.abs(gain(map, cx, cy)) > 0.02) panic(`drawBox: the gain under black is ${gain(map, cx, cy)}`); + if (Math.abs(gain(map, cx, cy)) > 0.02) panic(`composite: the gain under black is ${gain(map, cx, cy)}`); } - // many thin boxes: the same map as drawing them one by one on a copy without a shortcut (the order doesn't matter) + // many thin translucent white lines (translucent black wouldn't change the gain: it dims the SDR and HDR light alike): the same map as drawing them one by one on a copy without a shortcut (the order doesn't matter) { const a = await Image.decode(file), b = await Image.decode(file); - for (let y = 0; y < a.height; y += 8) a.drawBox(0, y, a.width, 1, 0x00000055); - for (let y = a.height - (a.height % 8 || 8); y >= 0; y -= 8) b.drawBox(0, y, b.width, 1, 0x00000055); + for (let y = 0; y < a.height; y += 8) a.composite(new Image(a.width, 1).fill(0xffffff55), {x: 0, y: y}); + for (let y = a.height - (a.height % 8 || 8); y >= 0; y -= 8) b.composite(new Image(b.width, 1).fill(0xffffff55), {x: 0, y: y}); let worst = 0; for (let y = 0; y < a.gainMap.height; y += 7) for (let x = 0; x < a.gainMap.width; x += 7) worst = Math.max(worst, Math.abs(gain(a.gainMap, x, y) - gain(b.gainMap, x, y))); - if (worst > 0.05) panic(`drawBox: scanlines in a different order give different maps (${worst})`); - if (mapBytes(a).equals(mapBytes(await Image.decode(file)))) panic('drawBox: scanlines should change the map'); + if (worst > 0.05) panic(`composite: scanlines in a different order give different maps (${worst})`); + if (mapBytes(a).equals(mapBytes(await Image.decode(file)))) panic('composite: scanlines should change the map'); } // a drawing brighter than the map's range grows it, and the pixels the drawing didn't touch keep their gain diff --git a/tests/gainmap.js b/tests/gainmap.js index 8713a21c..014ca1ec 100644 --- a/tests/gainmap.js +++ b/tests/gainmap.js @@ -155,8 +155,8 @@ const meanValue = (image, left, top, right, bottom) => { const gainAt = (img, value) => img.gainMap.min[0] + (img.gainMap.max[0] - img.gainMap.min[0]) * (value / 255) ** (1 / img.gainMap.gamma[0]); const outside = gainAt(image, meanValue(image, 0, 0, 512, 90)), before = gainAt(single, meanValue(single, 0, 0, 512, 90)); if (Math.abs(outside - before) > 0.02) panic(`composite: changed outside the box (${outside} vs ${before})`); - const boxed = single.clone().drawBox(0, 0, 64, 64, 0xffffffff); - if (Math.abs(meanValue(boxed, 0, 0, 60, 60) - stored(boxed.gainMap, 0)) > 1.5) panic('drawBox: boost left under the box'); + const boxed = single.clone().composite(new Image(64, 64).fill(0xffffffff), {x: 0, y: 0}); + if (Math.abs(meanValue(boxed, 0, 0, 60, 60) - stored(boxed.gainMap, 0)) > 1.5) panic('composite: boost left under the box'); // (an image with a map composited onto one without: the canvas gets one, its own part as the source's) const canvas = new Image(600, 500).fill(0x000000ff).composite(single, {x: 40, y: 60}); if (!canvas.gainMap || Math.abs(meanValue(canvas, 50, 70, 540, 150) - meanValue(single, 10, 10, 500, 90)) > 6) panic('composite onto a canvas: the map'); diff --git a/tests/misc.js b/tests/misc.js index 28930332..908512ed 100644 --- a/tests/misc.js +++ b/tests/misc.js @@ -134,16 +134,6 @@ if (Colors.fromHSLA(0, 0, 1, 0xff) !== 0xffffffff) } } -{ - const image = new Image(1, 1); - image.drawBox(0, 0, 1, 1, 0xff8000ff); - if (image.getPixelAt(0, 0) !== 0xff8000ff) - panic('static drawBox failed'); - - image.drawBox(0, 0, 1, 1, () => 0x00ff00ff); - if (image.getPixelAt(0, 0) !== 0x00ff00ff) - panic('fn drawBox failed'); -} { const image = new Image(1, 1); diff --git a/tests/options-only.js b/tests/options-only.js index 3c070cb8..fca401cf 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -71,7 +71,7 @@ const sameImage = (image, reference, label) => { await rejects(() => frame.trim(0xffffffff), 'Frame.trim(0xffffffff)'); if (frame.xOffset !== 4 || frame.yOffset !== 5 || frame.width !== 20) panic('Frame.trim(0xffffffff): the frame should be unchanged'); } - const solid = new Image(6, 6).fill(0xffffffff).drawBox(2, 2, 2, 2, 0x000000ff); + const solid = new Image(6, 6).fill(0xffffffff).composite(new Image(2, 2).fill(0x000000ff), {x: 2, y: 2}); if (solid.clone().trim({color: 0xffffffff}).width !== 2) panic('trim({ color }) should trim the color'); if (solid.clone().composite(patch, {x: 1, y: 1}).getPixelAt(1, 1) !== 0xff0000ff) panic('composite(patch, { x, y }) should place the patch'); if (solid.clone().sharpen({amount: 0}).getPixelAt(2, 2) !== 0x000000ff) panic('sharpen({ amount: 0 }) should not change the pixels'); diff --git a/tests/readme.js b/tests/readme.js index d82b95d4..3e9e7945 100644 --- a/tests/readme.js +++ b/tests/readme.js @@ -29,7 +29,7 @@ const font = name => fs.readFileSync('./tests/fonts/' + name); // a frosted glass card: the background behind it, blurred and lightened, with a soft shadow const card = {x: 40, y: 40, width: 520, height: 520, radius: 28}; - const shadow = new Image(card.width + 80, card.height + 80).drawBox(40, 48, card.width, card.height, 0x00000060).blur(16); + const shadow = new Image(card.width + 80, card.height + 80).composite(new Image(card.width, card.height).fill(0x00000060), {x: 40, y: 48}).blur(16); image.composite(shadow, {x: card.x - 40, y: card.y - 40}); const glass = image.region(card.x, card.y, card.width, card.height).blur(24) .composite(new Image(card.width, card.height).fill(0xffffffff), {opacity: 0.18}) @@ -73,7 +73,7 @@ const font = name => fs.readFileSync('./tests/fonts/' + name); } // a divider, then greetings in a few scripts - image.drawBox(left, card.y + 446, card.width - 80, 1, 0xffffff40); + image.composite(new Image(card.width - 80, 1).fill(0xffffff40), {x: left, y: card.y + 446}); const greeting = await Image.renderText('Bom dia · مرحبا · नमस्ते 😀', { font: scripts, size: 26, color: 0xffffffe0 }); image.composite(greeting, {x: left, y: card.y + 446 + (card.height - 446 - greeting.height) / 2}); diff --git a/tests/regressions.js b/tests/regressions.js index 187d3eec..79e23cfa 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -62,13 +62,6 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); assert(threw, 'crop: region outside the image should throw'); } - // drawBox outside of the image - { - new Image(10, 10).drawBox(0, 20, 5, 5, 0xffffffff).drawBox(20, 0, 5, 5, 0xffffffff).drawBox(-10, -10, 5, 5, 0xffffffff); - const image = new Image(4, 4).drawBox(-2, -2, 3, 3, 0xffffffff); - assert(image.getPixelAt(0, 0) === 0xffffffff && image.getPixelAt(1, 1) === 0, 'drawBox: clipping failed'); - } - // averageColor is weighted by alpha { assert(new Image(2, 2).fill(Colors.fromRGBA(255, 0, 0, 128)).averageColor() === 0xff0000ff, 'averageColor: semi-transparent red'); @@ -128,9 +121,6 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); new Image(2, 1).fill((x, y) => (filled.push([x, y]), 0)); assert(filled.join(';') === '0,0;1,0', 'fill callback should be 0-based'); - const box = new Image(3, 3).drawBox(1, 1, 1, 1, 0xffffffff); - assert(box.getPixelAt(1, 1) === 0xffffffff && box.getPixelAt(0, 0) === 0, 'drawBox should be 0-based'); - const target = new Image(3, 3).composite(new Image(1, 1).fill(0xffffffff), {x: 1, y: 1}); assert(target.getPixelAt(1, 1) === 0xffffffff, 'composite and getPixelAt should agree'); @@ -267,32 +257,12 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); } } - // circles are antialiased and symmetric around the pixel center - { - const circle = new Image(9, 9).drawCircle(4, 4, 3.5, 0xffffffff); - const alpha = (x, y) => circle.getPixelAt(x, y) & 0xff; - for (let y = 0; y < 9; y++) - for (let x = 0; x < 9; x++) - assert(alpha(x, y) === alpha(8 - x, y) && alpha(x, y) === alpha(y, x), `drawCircle should be symmetric (${x}, ${y})`); - assert(alpha(4, 4) === 255 && alpha(0, 0) === 0 && alpha(1, 2) > 0 && alpha(1, 2) < 255, 'drawCircle should antialias its edge'); - - const cropped = new Image(6, 6).fill(0xffffffff).cropCircle(); - const a = (x, y) => cropped.getPixelAt(x, y) & 0xff; - for (let y = 0; y < 6; y++) - for (let x = 0; x < 6; x++) - assert(a(x, y) === a(5 - x, y) && a(x, y) === a(x, 5 - y), `cropCircle should be symmetric (${x}, ${y})`); - - const feathered = new Image(41, 41).fill(0xffffffff).cropCircle({ feathering: 0.5 }); - const f = x => feathered.getPixelAt(x, 20) & 0xff; - assert(f(20) === 255 && f(12) === 255 && f(5) > 0 && f(5) < 255 && f(0) < 32, 'cropCircle feathering should only soften the outer part'); - } - // dominant colors are exact and dark saturated colors aren't treated as black { assert(new Image(4, 4).fill(0xff0000ff).dominantColor() === 0xff0000ff, 'dominantColor of pure red'); assert(new Image(4, 4).fill(0x336699ff).dominantColor() === 0x336699ff, 'dominantColor of #336699'); - const navy = new Image(10, 10).fill(0x00006eff).drawBox(0, 0, 2, 2, 0xc8c800ff); + const navy = new Image(10, 10).fill(0x00006eff).composite(new Image(2, 2).fill(0xc8c800ff), {x: 0, y: 0}); assert(navy.dominantColor() === 0x00006eff, 'dominantColor: navy should beat a small yellow area'); assert(new Image(4, 4).fill(0x000000ff).dominantColor() === 0x000000ff, 'dominantColor: all black falls back to black'); assert(new Image(4, 4).dominantColor() === 0x000000ff, 'dominantColor: transparent image'); @@ -725,8 +695,8 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // options objects as alternatives to positional arguments { const base = new Image(20, 20).fill(0xff0000ff); - assert(base.clone().rotate(45, {resize: false}).width === 20 && base.clone().rotate(45).width > 20, 'rotate options'); - const navy = new Image(10, 10).fill(0x00002aff).drawBox(0, 0, 3, 3, 0xc8c800ff); + assert(base.clone().rotate(45, {expand: false}).width === 20 && base.clone().rotate(45).width > 20, 'rotate options'); + const navy = new Image(10, 10).fill(0x00002aff).composite(new Image(3, 3).fill(0xc8c800ff), {x: 0, y: 0}); assert(navy.dominantColor({threshold: 0}) === 0x00002aff, 'dominantColor options'); } @@ -871,16 +841,19 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // colorToAlpha removes the color, so compositing the result over that color gives the original back (#39) { - const image = new Image(40, 40).fill(0xffffffff).drawCircle(20, 20, 12, 0xff0000ff); + const image = new Image(40, 40).fill(0xffffffff).composite(new Image(24, 24).fill(0xff0000ff).cropCircle(), {x: 8, y: 8}); const cut = image.clone().colorToAlpha(); assert(cut.getPixelAt(0, 0) === 0xffffff00, 'colorToAlpha: the background should become transparent'); assert(cut.getPixelAt(20, 20) === 0xff0000ff, 'colorToAlpha: other colors should stay opaque'); const back = new Image(40, 40).fill(0xffffffff).composite(cut); assert(Buffer.from(back.bitmap).equals(Buffer.from(image.bitmap)), 'colorToAlpha: compositing over the color should give the original'); - const edge = cut.getRGBAAt(32, 20); - assert(edge[3] > 0 && edge[3] < 255 && edge[0] === 255 && edge[1] === 0, 'colorToAlpha: antialiased edges should become translucent red'); + // (an antialiased edge pixel of the circle: the first one on the diagonal that is neither the color nor red) + let ex = 8; + while ((image.getPixelAt(ex, ex) & 0xffffff00) >>> 0 === 0xffffff00) ex++; + const edge = cut.getRGBAAt(ex, ex); + assert(edge[3] > 0 && edge[3] < 255 && edge[0] === 255 && edge[1] === 0, `colorToAlpha: antialiased edges should become translucent red (${edge})`); const loose = image.clone().colorToAlpha({color: 0xffffffff, transparencyThreshold: 0.5}); - assert(loose.getRGBAAt(32, 20)[3] < edge[3], 'colorToAlpha: the threshold should make more of the edge transparent'); + assert(loose.getRGBAAt(ex, ex)[3] < edge[3], 'colorToAlpha: the threshold should make more of the edge transparent'); } // slice-by-16 crc32 matches zlib for every length around the 16 byte steps and for unaligned views (#12) @@ -1042,7 +1015,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const calls = { rgbaToColor: v => Colors.fromRGBA(v, 0, 0, 255), hslaToColor: v => Colors.fromHSLA(v, 0.5, 0.5, 1), colorToRGBA: v => Colors.toRGBA(v), getPixelAt: v => image().getPixelAt(v, 1), setPixelAt: v => image().setPixelAt(1, 1, v), fill: v => image().fill(v), contain: v => image().contain(v, 4), - crop: v => image().crop(v, 0, 2, 2), trim: v => image().trim({color: v}), drawBox: v => image().drawBox(0, 0, v, 2, 0), drawCircle: v => image().drawCircle(2, 2, v, 0), + crop: v => image().crop(v, 0, 2, 2), trim: v => image().trim({color: v}), cropCircle: v => image().cropCircle({ feathering: v }), opacity: v => image().opacity(v), red: v => image().red(v), lightness: v => image().lightness(v), composite: v => image().composite(image(), {x: v, y: 0}), hueShift: v => image().hueShift(v), brightness: v => image().brightness(v), gamma: v => image().gamma(v), colorToAlpha: v => image().colorToAlpha({color: v}), rotate: v => image().rotate(v, {expand: false}), gradient: v => Colors.gradient({0: v, 1: 0}), diff --git a/tests/targets/readme.png b/tests/targets/readme.png index a17d4256900ca8e03b532ac1a89276a6467d2996..88d2481000088373fe875e2a489d844746e6d52e 100644 GIT binary patch literal 148944 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 z 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 +QcNv141fd{$r>Rq$z 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ⅈDU~F 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 diff --git a/tests/trim.js b/tests/trim.js index 416970b0..517c6889 100644 --- a/tests/trim.js +++ b/tests/trim.js @@ -12,7 +12,7 @@ const size = image => `${image.width}x${image.height}`; { const image = new Image(10, 8); image.setPixelAt(1, 1, 0xffffff00); // transparent white - image.drawBox(3, 4, 4, 2, 0xff0000ff); + image.composite(new Image(4, 2).fill(0xff0000ff), {x: 3, y: 4}); image.setPixelAt(9, 2, 0x00ff00ff); image.trim(); if (size(image) !== '7x4') panic(`trim: ${size(image)} instead of 7x4`); @@ -21,7 +21,7 @@ const size = image => `${image.width}x${image.height}`; // the top left pixel's color by default, or a given one { - const make = () => new Image(6, 6).fill(0xffffffff).drawBox(3, 2, 2, 3, 0x000000ff); + const make = () => new Image(6, 6).fill(0xffffffff).composite(new Image(2, 3).fill(0x000000ff), {x: 3, y: 2}); if (size(make().trim()) !== '2x3') panic('trim: solid background not trimmed'); if (size(make().trim({color: 0x000000ff})) !== '6x6') panic('trim: explicit color should only trim that color'); const marked = make().setPixelAt(1, 1, 0xff0000ff); @@ -43,7 +43,7 @@ const size = image => `${image.width}x${image.height}`; // frames keep their position { const frame = new Frame(10, 10, {duration: 100, xOffset: 5, yOffset: 7}); - frame.drawBox(2, 3, 4, 4, 0xffffffff); + frame.composite(new Image(4, 4).fill(0xffffffff), {x: 2, y: 3}); frame.trim(); if (size(frame) !== '4x4' || frame.xOffset !== 7 || frame.yOffset !== 10) panic(`trim: frame ${size(frame)} at ${frame.xOffset},${frame.yOffset}`); } diff --git a/tests/webp-lossy.js b/tests/webp-lossy.js index 56f7d798..eef3cb43 100644 --- a/tests/webp-lossy.js +++ b/tests/webp-lossy.js @@ -139,7 +139,7 @@ const alphaReferences = { // lossy animations const frames = [0xff0000ff, 0x00ff00ff, 0x0000ff80].map((color, i) => { const frame = new Image(40, 30).fill(color); - frame.drawBox(i * 10, 5, 10, 10, 0xffffffff); + frame.composite(new Image(10, 10).fill(0xffffffff), {x: i * 10, y: 5}); return frame; }); const animation = new Animation(frames.map(f => Frame.from(f, {duration: 100})), {loopCount: 0}); From 9f36d487d05845341ff1535ed30414fa9858faed Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:33:07 +0000 Subject: [PATCH 051/188] test the reshaped API --- tests/api-shapes.js | 157 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 tests/api-shapes.js diff --git a/tests/api-shapes.js b/tests/api-shapes.js new file mode 100644 index 00000000..7fd750e5 --- /dev/null +++ b/tests/api-shapes.js @@ -0,0 +1,157 @@ +const {Image, Frame, Animation, GainMap, Colors} = require('..'); + +const assert = (condition, message) => { + if (condition) return; + console.error(message); + process.exit(1); +}; +const throws = (call, type, message) => { + let error = null; + try { call(); } catch (e) { error = e; } + assert(error instanceof type, `${message}: should throw a ${type.name}, got ${error}`); +}; + +(async () => { + const photo = () => new Image(40, 20).fill((x, y) => Colors.fromRGBA(x * 6, y * 12, 128, 255)); + + // contain() pads to the box (transparent, or the background color); resize({ fit: 'inside' }) only resizes + { + const padded = photo().contain(30, 30); + assert(padded.width === 30 && padded.height === 30, `contain: ${padded}`); + assert(padded.getPixelAt(0, 0) === 0 && padded.getPixelAt(15, 15) >>> 0 !== 0, 'contain: transparent bars, the image in the middle'); + const black = photo().contain(30, 30, {background: 0x000000ff}); + assert(black.getPixelAt(0, 0) === 0x000000ff, 'contain: background color in the bars'); + const inside = photo().resize({width: 30, height: 30, fit: 'inside'}); + assert(inside.width === 30 && inside.height === 15, `resize fit inside: ${inside}`); + const filled = photo().resize({width: 30, height: 30}); + assert(filled.width === 30 && filled.height === 30, 'resize: fill is the default'); + throws(() => photo().resize({width: 30, height: 30, fit: 'contain'}), TypeError, 'resize: unknown fit'); + throws(() => photo().contain(30, 30, {background: NaN}), RangeError, 'contain: NaN background'); + const frame = Frame.from(photo(), {xOffset: 10, yOffset: 4}); + frame.resize({width: 20, height: 20, fit: 'inside'}); + assert(frame.width === 20 && frame.height === 10 && frame.xOffset === 5 && frame.yOffset === 2, `Frame.resize fit inside: ${frame} ${frame.xOffset},${frame.yOffset}`); + } + + // Frame duration and offsets are checked when they are set, and keep their value when the check fails + { + const frame = new Frame(4, 4, {duration: 50, xOffset: 1, yOffset: 2}); + throws(() => { frame.duration = -1; }, RangeError, 'frame.duration = -1'); + throws(() => { frame.duration = NaN; }, RangeError, 'frame.duration = NaN'); + throws(() => { frame.xOffset = Infinity; }, RangeError, 'frame.xOffset = Infinity'); + throws(() => { frame.yOffset = NaN; }, RangeError, 'frame.yOffset = NaN'); + assert(frame.duration === 50 && frame.xOffset === 1 && frame.yOffset === 2, 'frame: a failed set keeps the value'); + frame.duration = '40'; + assert(frame.duration === 40, 'frame.duration: numeric strings are converted'); + const copy = frame.clone(); + assert(copy.duration === 40 && copy.xOffset === 1 && copy.yOffset === 2, 'Frame.clone keeps duration and offsets'); + } + + // an animation's canvas: its own size once set, frames outside it are clipped when encoding + { + const red = Frame.from(new Image(10, 10).fill(0xff0000ff), {xOffset: 5, yOffset: 5}); + const auto = new Animation([red.clone()]); + assert(auto.width === 15 && auto.height === 15, `auto canvas: ${auto}`); + const sized = new Animation([red.clone()], {width: 30, height: 20}); + assert(sized.width === 30 && sized.height === 20, `canvas option: ${sized}`); + throws(() => new Animation([], {width: 30}), TypeError, 'canvas: width without height'); + throws(() => new Animation([], {width: 0, height: 10}), RangeError, 'canvas: 0 wide'); + for (const encode of ['encodeGIF', 'encodeAPNG', 'encodeWebP']) { + const decoded = await Animation.decode(await sized[encode]()); + assert(decoded.width === 30 && decoded.height === 20, `${encode}: the canvas should be kept, got ${decoded}`); + } + // a frame partly outside: only the part inside is encoded + const over = new Animation([Frame.from(new Image(10, 10).fill(0x00ff00ff), {xOffset: 25, yOffset: 15}), Frame.from(new Image(2, 2).fill(0xff0000ff), {xOffset: 40, yOffset: 40})], {width: 30, height: 20}); + const back = await Animation.decode(await over.encodeAPNG({optimize: false})); + assert(back.width === 30 && back.height === 20 && back.length === 2, `clipped frames: ${back} ${back.length}`); + // (optimized, the empty second frame merges into the first) + const merged = await Animation.decode(await over.encodeAPNG()); + assert(merged.length === 1 && merged.duration === 200, `clipped frames, optimized: ${merged} ${merged.length}`); + assert(back.frames[0].getPixelAt(29, 19) === 0x00ff00ff && back.frames[0].getPixelAt(24, 19) === 0, 'clipped frames: the part inside the canvas'); + assert(over.frames[0].width === 10, 'encoding should not change the frames'); + // decoded animations keep the file's canvas, even when the frames would say otherwise + const decoded = await Animation.decode(await sized.encodeGIF()); + decoded.frames[0].crop(0, 0, 5, 5); + assert(decoded.width === 30 && decoded.height === 20, 'decoded canvas stays'); + } + + // Animation.clone, crop, contain, cover and resize({ fit: 'inside' }) + { + const make = () => new Animation([ + Frame.from(new Image(20, 10).fill(0xff0000ff), {duration: 50}), + Frame.from(new Image(10, 10).fill(0x0000ffff), {xOffset: 10, duration: 70}) + ], {width: 20, height: 10, loopCount: 3}); + const original = make(), copy = original.clone(); + copy.frames[0].fill(0x00ff00ff); + copy.frames[1].xOffset = 0; + assert(original.frames[0].getPixelAt(0, 0) === 0xff0000ff && original.frames[1].xOffset === 10, 'clone: independent frames'); + assert(copy.loopCount === 3 && copy.width === 20 && copy.height === 10 && copy.frames[1].duration === 70, 'clone: loop count, canvas, durations'); + + const cropped = make().crop(5, 2, 10, 6); + assert(cropped.width === 10 && cropped.height === 6, `crop: canvas ${cropped}`); + assert(cropped.frames[0].width === 10 && cropped.frames[0].xOffset === 0 && cropped.frames[1].xOffset === 5 && cropped.frames[1].width === 5, 'crop: frames cut and moved'); + throws(() => make().crop(30, 0, 5, 5), RangeError, 'crop outside the canvas'); + + const contained = make().contain(40, 40); + assert(contained.width === 40 && contained.height === 40 && contained.frames[0].width === 40 && contained.frames[0].yOffset === 10, `contain: ${contained} ${contained.frames[0]} ${contained.frames[0].yOffset}`); + const withBackground = make().contain(40, 40, {background: 0xffffffff}); + assert(withBackground.frames.every(f => f.width === 40 && f.height === 40 && f.getPixelAt(0, 0) === 0xffffffff), 'contain: background frames'); + assert(withBackground.frames[1].getPixelAt(30, 20) === 0x0000ffff && withBackground.frames[1].getPixelAt(5, 20) === 0xff0000ff, 'contain: background frames show the composited animation'); + assert(withBackground.frames.map(f => f.duration).join() === '50,70', 'contain: durations kept'); + + const covered = make().cover(10, 10); + assert(covered.width === 10 && covered.height === 10, `cover: ${covered}`); + const inside = make().resize({width: 10, height: 10, fit: 'inside'}); + assert(inside.width === 10 && inside.height === 5, `Animation.resize fit inside: ${inside}`); + } + + // GainMap parameters: min and max, checked on set, copies on get + { + const map = new GainMap(new Image(4, 4).fill(0x808080ff), {min: -1, max: [2, 3, 4]}); + assert(map.min.join() === '-1,-1,-1' && map.max.join() === '2,3,4' && map.hdrCapacityMax === 4, 'GainMap: min/max options'); + map.max[0] = 99; + assert(map.max[0] === 2, 'GainMap: getters return copies'); + throws(() => { map.max = NaN; }, RangeError, 'GainMap max = NaN'); + throws(() => { map.min = [1, 2]; }, RangeError, 'GainMap min of 2 values'); + throws(() => { map.gamma = 0; }, RangeError, 'GainMap gamma = 0'); + throws(() => { map.hdrCapacityMin = -1; }, RangeError, 'GainMap hdrCapacityMin = -1'); + throws(() => { map.useBaseColorSpace = 1; }, TypeError, 'GainMap useBaseColorSpace = 1'); + assert(map.max.join() === '2,3,4' && map.gamma.join() === '1,1,1' && map.hdrCapacityMin === 0, 'GainMap: a failed set keeps the value'); + map.max = 5; + assert(map.max.join() === '5,5,5', 'GainMap: one number for all channels'); + } + + // JPEG chroma subsampling: the default by quality, or as given + { + const image = photo().resize(160, 80); + const at80 = await image.encodeJPEG({quality: 80, metadata: {creationTime: 0, software: ''}}); + const at80sub = await image.encodeJPEG({quality: 80, subsampling: '4:2:0', metadata: {creationTime: 0, software: ''}}); + const at80full = await image.encodeJPEG({quality: 80, subsampling: '4:4:4', metadata: {creationTime: 0, software: ''}}); + assert(Buffer.from(at80).equals(Buffer.from(at80sub)), 'JPEG: 4:2:0 is the default up to quality 90'); + assert(!Buffer.from(at80).equals(Buffer.from(at80full)), 'JPEG: 4:4:4 at quality 80'); + const at95 = await image.encodeJPEG({quality: 95, metadata: {creationTime: 0, software: ''}}); + const at95full = await image.encodeJPEG({quality: 95, subsampling: '4:4:4', metadata: {creationTime: 0, software: ''}}); + assert(Buffer.from(at95).equals(Buffer.from(at95full)), 'JPEG: 4:4:4 is the default above quality 90'); + let error = null; + try { await image.encodeJPEG({subsampling: '4:2:2'}); } catch (e) { error = e; } + assert(error instanceof TypeError, 'JPEG: unknown subsampling'); + const decoded = await Image.decode(at80full); + assert(decoded.width === 160, 'JPEG 4:4:4 decodes'); + } + + // hues are in degrees; gradients take arrays + { + assert(Colors.fromHSL(120, 1, 0.5) === 0x00ff00ff && Colors.fromHSL(240, 1, 0.5) === 0x0000ffff && Colors.fromHSL(360, 1, 0.5) === 0xff0000ff, 'fromHSL: degrees'); + assert(Colors.fromHSLA(-120, 1, 0.5, 0.5) === 0x0000ff80, 'fromHSLA: negative degrees wrap'); + assert(Math.round(Colors.toHSLA(0x0000ffff)[0]) === 240, 'toHSLA: degrees'); + const gradient = Colors.gradient([0x000000ff, 0xffffffff]); + assert(gradient(0) === 0x000000ff && gradient(1) === 0xffffffff && gradient(0.5) === 0x7f7f7fff, `gradient of an array: ${gradient(0.5).toString(16)}`); + assert(Colors.gradient([0xff0000ff])(0.3) === 0xff0000ff, 'gradient of one color'); + } + + // renamed parameters and the American spelling + { + const gray = new Image(1, 1).fill(0xff0000ff).grayscale(); + assert(gray.getPixelAt(0, 0) === 0x4c4c4cff, `grayscale: ${gray.getPixelAt(0, 0).toString(16)}`); + assert(new Image(1, 1).greyscale === undefined && new Image(1, 1).drawBox === undefined && new Image(1, 1).fit === undefined, 'removed methods'); + } +})(); From 4b1f238a56ee583e0ed3703d4d9971409c80eff9 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:33:07 +0000 Subject: [PATCH 052/188] changelog and guide: reshaped API --- CHANGELOG.md | 25 ++++++++++ guides/Migrating to 4.0.md | 100 ++++++++++++++++++++++++++++++------- 2 files changed, 106 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a10ca0da..496b4659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,31 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **Removed and narrowed exported types**: `TextOptions` is `RenderTextOptions`, `FrameDisposalModeId` is removed (`FrameDisposalModeName` has the names), `AdjustOptions` is removed with `absolute`, `PNGMetadata` is `Metadata`, and `AnimationEncoderOptions`, `WebPAnimationEncoderOptions`, `GIFAnimationEncoderOptions` and `APNGAnimationEncoderOptions` are `AnimationEncodeOptions`, `WebPAnimationEncodeOptions`, `GIFEncodeOptions` and `APNGEncodeOptions`. `ResizeMode` and `SVGScaleMode` are narrowed to the lower-case names (`'area'`, `'scale'`, ...): the `RESIZE_*` strings and the numbers 1-3 are no longer part of the types. +- **`contain()` pads to the box, `fit()` is removed**: `image.contain(w, h)` resizes the image to fit inside the box and centers it on a canvas of exactly that size, like CSS' `object-fit: contain` (what `fit()` did), with transparent bars or a `background` color. The resize without bars, which `contain()` did, is `resize({ width, height, fit: 'inside' })`. + +- **Hues are in degrees**: `Colors.fromHSL()`, `Colors.fromHSLA()` and `Colors.toHSLA()` take and return the hue in degrees (0-360, like CSS' `hsl()` and `hueShift()`) instead of 0-1. + +- **`drawBox()` and `drawCircle()` are removed**: `image.composite(new Image(w, h).fill(color), { x, y })` draws a box, with `.cropCircle()` a circle. Compositing blends a translucent color over the image, where `drawBox()` replaced the pixels. + +- **`renderSVG()` takes `width`, `height` or `scale`**: `{ size: 512, mode: 'width' }` is `{ width: 512 }`, `{ size: 2 }` is `{ scale: 2 }`; `SVGScaleModes` and `SVGScaleMode` are removed. More than one of them throws a `TypeError`. + +- **Renamed**: `greyscale()` is `grayscale()` (American spelling throughout), the `resize` option of `rotate()` is `expand` (like `transform()`, `outline()` and `dropShadow()`), `GainMap`'s `gainMapMin` and `gainMapMax` (properties and options) are `min` and `max`, and the parameters of `setPixelAt()`, `lightness()` and `saturation()` are `color` and `factor`. + +- **Fewer types and classes**: `TextLayout` isn't exported, `renderText()` takes the layout as a plain object (`layout: { maxWidth: 300 }`). `AnimationDecodeOptions` is `DecodeOptions`, which all decode functions take. `encodeTGA()` takes `TGAEncodeOptions`; `EncodeOptions` is the `metadata` that every encoder's options extend, and the `gainMap` option of the JPEG, AVIF and JPEG XL encoders is `boolean | GainMapEmbedOptions`. + +- **Values are checked when they are set**: `frame.duration`, `frame.xOffset`, `frame.yOffset` and the parameters of a `GainMap` throw for invalid values (instead of failing in a later encode) and keep their value. + +- **An animation has its own canvas size**: decoded animations keep the file's, `new Animation(frames, { width, height })` sets one, and `resize()` sets the new one. Without one it is the frames' extent, as before. Frames (or their parts) outside the canvas aren't encoded. + +- **The encoders return `Promise>`**, which `new Blob()`, `new Response()` and `crypto.subtle` take without a cast in TypeScript 5.9 and newer (needs TypeScript 5.7). + +### New + +- `Animation.clone()`, `Animation.crop()`, `Animation.contain()` and `Animation.cover()`, working on the canvas like their `Image` counterparts (`contain()` with a `background` makes full canvas frames of the composited animation). +- `resize({ width, height, fit: 'inside' })` on images, frames and animations: as large as fits inside the box, keeping the aspect ratio. +- `encodeJPEG({ subsampling: '4:4:4' | '4:2:0' })`; the default stays 4:2:0 up to quality 90 and 4:4:4 above. +- `Colors.gradient()` takes an array of evenly spaced colors, like the `stops` of the gradient functions. + ## 3.20.1 ### Performance diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index 4ccc1359..ef0abed9 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -1,7 +1,7 @@ # Migrating to 4.0 -ImageScript 4.0 removes everything 3.x deprecated, and changes three behaviours. Most of the removals are mechanical: -the compiler points at them. The three behaviour changes are not deprecations, nothing warns about them, and code can +ImageScript 4.0 removes everything 3.x deprecated, and changes five behaviors. Most of the removals are mechanical: +the compiler points at them. The five behavior changes are not deprecations, nothing warns about them, and code can keep compiling while it does something else. Read the first section even if your build is clean. Node.js 22.13 or newer is required, as it was for 3.x (`engines` in `package.json`). @@ -12,7 +12,7 @@ The recommended path: strikethrough, and the deprecation text names the replacement. `tsc` doesn't fail on them, so use the editor's problems list, or a lint rule for deprecated APIs (typescript-eslint has `no-deprecated`), to get the full list. 2. Fix every deprecation hint, using the tables below. -3. Switch to 4.0 and go through the three behaviour changes. The [checklist](#checklist) at the end has grep patterns +3. Switch to 4.0 and go through the five behavior changes. The [checklist](#checklist) at the end has grep patterns for finding the code they affect. ## Behavior changes @@ -108,10 +108,33 @@ forever.) `Infinity` is always fine. Search your code for: `loopCount: -1`, `loopCount = -1`, `loopCount === -1` and `loopCount < 0`. +### `contain()` pads to the box + +`image.contain(width, height)` now works like CSS' `object-fit: contain`: the image is resized to fit inside the box +and centered on a canvas of exactly that size, with transparent bars (or the `background` color). That is what +`fit()` did in 3.x; `fit()` is removed. 3.x's `contain()`, which only resized and could return a smaller image, is +`resize({ width, height, fit: 'inside' })`. + +| 3.x | 4.0 | +| --- | --- | +| `image.contain(800, 600)` (at most 800x600, no bars) | `image.resize({ width: 800, height: 600, fit: 'inside' })` | +| `image.fit(800, 600)` (exactly 800x600, transparent bars) | `image.contain(800, 600)`, or `image.contain(800, 600, { background: 0x000000ff })` for black bars | + +### Hues are in degrees + +`Colors.fromHSL()`, `Colors.fromHSLA()` and `Colors.toHSLA()` take and return the hue in degrees (0-360, like CSS' +`hsl()` and like `hueShift()`), no longer as a fraction (0-1). Saturation, lightness and alpha stay 0-1. An old hue +still works without an error, but gives a different color: `Colors.fromHSL(0.5, 1, 0.5)` was cyan and is now red. + +| 3.x | 4.0 | +| --- | --- | +| `Colors.fromHSL(h, s, l)` with `h` in 0-1 | `Colors.fromHSL(h * 360, s, l)` | +| `Colors.toHSLA(color)[0]` in 0-1 | `Colors.toHSLA(color)[0] / 360` for the old value | + ## Removed APIs Each table maps a 3.x API to its 4.0 replacement. The deprecation hint in 3.x gives the same mapping. The replacements -are documented on their own pages: {@link ResizeModes}, {@link SVGScaleModes}, {@link FrameDisposalModes}, +are documented on their own pages: {@link ResizeModes}, {@link FrameDisposalModes}, {@link Colors}, {@link Frame}, {@link Animation}, {@link AnimationEncoder} and {@link Metadata}. ### Constants and modes @@ -124,15 +147,15 @@ are documented on their own pages: {@link ResizeModes}, {@link SVGScaleModes}, { | `Image.RESIZE_AREA` | `ResizeModes.AREA` (`'area'`) | | `Image.RESIZE_AUTO`, `ResizeModes.AUTO` | Pass only the width or the height: `image.resize({ width: 256 })` | | The upper-case mode strings, `'RESIZE_BILINEAR'` and the others | The lower-case names, `'bilinear'` and the others | -| The `ResizeMode` and `SVGScaleMode` types, with the upper-case strings or the numbers 1-3 | Narrowed to the lower-case names: `ResizeMode` is `'nearest-neighbor'`, `'bilinear'`, `'bicubic'` or `'area'`, `SVGScaleMode` is `'scale'`, `'width'` or `'height'` | -| `Image.SVG_MODE_SCALE` (or `1`) | `SVGScaleModes.SCALE` (`'scale'`) | -| `Image.SVG_MODE_WIDTH` (or `2`) | `SVGScaleModes.WIDTH` (`'width'`) | -| `Image.SVG_MODE_HEIGHT` (or `3`) | `SVGScaleModes.HEIGHT` (`'height'`) | +| The `ResizeMode` type, with the upper-case strings or the numbers 1-3 | Narrowed to the lower-case names: `'nearest-neighbor'`, `'bilinear'`, `'bicubic'` or `'area'` | +| The `SVGScaleMode` type | Removed with the SVG modes, see [renamed APIs](#renamed-and-reshaped-apis) | +| `Image.SVG_MODE_SCALE` (or `1`) | `Image.renderSVG(svg, { scale: 2 })`, see [renamed APIs](#renamed-and-reshaped-apis) | +| `Image.SVG_MODE_WIDTH` (or `2`) | `Image.renderSVG(svg, { width: 512 })`, see [renamed APIs](#renamed-and-reshaped-apis) | +| `Image.SVG_MODE_HEIGHT` (or `3`) | `Image.renderSVG(svg, { height: 512 })`, see [renamed APIs](#renamed-and-reshaped-apis) | | `Frame.DISPOSAL_ANY`, `Frame.DISPOSAL_KEEP`, `Frame.DISPOSAL_PREVIOUS`, `Frame.DISPOSAL_BACKGROUND` | `FrameDisposalModes.ANY`, `.KEEP`, `.PREVIOUS`, `.BACKGROUND` | | Numeric disposal ids: `frame.disposalMode = 3`, `disposalMode: 3` in the frame options | The names: `frame.disposalMode = 'background'`, `disposalMode: 'background'`. `FrameDisposalModeId` is removed | -The old string and number values are not accepted any more, so `image.resize(256, 256, 'RESIZE_BICUBIC')` and -`renderSVG(svg, { mode: 2 })` throw. +The old string and number values are not accepted any more, so `image.resize(256, 256, 'RESIZE_BICUBIC')` throws. ### Colors, pixels and encoding on `Image` @@ -217,7 +240,7 @@ The option types of the animation encoders are renamed, and each one takes `loop | `Image.renderText(font, size, text, color, layout, style)` | `Image.renderText(text, { font, size, color, layout, ...style })` | | `Image.renderText(font, size, text, options)` | `Image.renderText(text, { font, size, ...options })` | | `TextOptions` (type) | `RenderTextOptions` | -| `new TextLayout({ verticalAlign: 'right' })`, `horizontalAlign: 'bottom'` (the other axis' values) | `horizontalAlign` takes `'left'`, `'center'` or `'right'`, `verticalAlign` takes `'top'`, `'middle'` or `'bottom'`. Other values throw a `TypeError` | +| `layout: { verticalAlign: 'right' }`, `horizontalAlign: 'bottom'` (the other axis' values) | `horizontalAlign` takes `'left'`, `'center'` or `'right'`, `verticalAlign` takes `'top'`, `'middle'` or `'bottom'`. Other values throw a `TypeError` | | `image.cropCircle(true)` | `image.cropCircle({ size: 'cover' })` | | `image.cropCircle(true, feathering)` | `image.cropCircle({ size: 'cover', feathering })` | | `image.cropCircle(false)` | `image.cropCircle()`, the default `size: 'contain'` | @@ -225,7 +248,7 @@ The option types of the animation encoders are renamed, and each one takes `loop | `PNGMetadata` (type) | `Metadata`, which all encoders take | `renderText()` takes the text first. The font, the size, the color and the layout are all in the options object, and -`layout` takes a {@link TextLayout} instance or its options. The options are documented in {@link RenderTextOptions}. +`layout` takes the layout options (a plain object, see {@link TextLayoutOptions}). The options are documented in {@link RenderTextOptions}. Crop options are in {@link CropCircleOptions}. A key that is not an option is ignored without an error, so `cropCircle({ max: true })` quietly becomes the default `contain`: change it to `size: 'cover'`. @@ -237,17 +260,17 @@ object under the names of their options. The options of each method are on its o {@link DominantColorOptions}, {@link RoundCornersOptions}, {@link FisheyeOptions}, {@link TrimOptions}, {@link ColorToAlphaOptions}, {@link PNGEncodeOptions}, {@link JPEGEncodeOptions}, {@link SVGOptions} and {@link FrameOptions}. The animation options are {@link GIFEncodeOptions}, {@link APNGEncodeOptions}, -{@link AnimationDecodeOptions} and {@link GainMapEncodeOptions} for `GainMap.encode()`. +{@link DecodeOptions} and {@link GainMapEncodeOptions} for `GainMap.encode()`. | 3.x | 4.0 | | --- | --- | | `image.resize(256, 256, 'bicubic')`, `image.resize(256, 256, ResizeModes.AREA)` | `image.resize(256, 256, { mode: 'bicubic' })` | | `image.scale(0.5, 'area')`, `image.scale(0.5, ResizeModes.AREA)` | `image.scale(0.5, { mode: 'area' })` | | `image.resize(100)` (width only, the height left out) | `image.resize({ width: 100 })`, which keeps the aspect ratio. The positional form throws a `TypeError` without a height | -| `image.contain(w, h, mode)`, `image.fit(w, h, mode)`, `image.cover(w, h, mode)` | `image.contain(w, h, { mode })`, and the same for `fit` and `cover` | +| `image.contain(w, h, mode)`, `image.fit(w, h, mode)`, `image.cover(w, h, mode)` | `image.resize({ width: w, height: h, fit: 'inside', mode })`, `image.contain(w, h, { mode })` and `image.cover(w, h, { mode })`, see [`contain()` pads to the box](#contain-pads-to-the-box) | | `Frame` and `Animation`: `resize(w, h, mode)` | `resize(w, h, { mode })` | | `image.composite(logo, 10, 20, { opacity: 0.5 })` | `image.composite(logo, { x: 10, y: 20, opacity: 0.5 })` | -| `image.rotate(45, false)` | `image.rotate(45, { resize: false })` | +| `image.rotate(45, false)` | `image.rotate(45, { expand: false })` | | `image.sharpen(2, 3)` | `image.sharpen({ amount: 2, radius: 3 })` | | `image.dominantColor(true, true, 15)` | `image.dominantColor({ ignoreBlack: true, ignoreWhite: true, threshold: 15 })` | | `image.roundCorners(8)` | `image.roundCorners({ radius: 8 })` | @@ -260,7 +283,7 @@ object under the names of their options. The options of each method are on its o | `animation.encodeGIF(90)` | `animation.encodeGIF({ quality: 90 })` | | `animation.encodeAPNG(9)` | `animation.encodeAPNG({ compression: 9 })` | | `gainMap.encode(90)` | `gainMap.encode({ quality: 90 })` | -| `Image.renderSVG(svg, 2, 'width')` | `Image.renderSVG(svg, { size: 2, mode: 'width' })` | +| `Image.renderSVG(svg, 512, 'width')` | `Image.renderSVG(svg, { width: 512 })` (and `{ height }`, `{ scale }`) | | `new Frame(w, h, duration, x, y, disposal)` | `new Frame(w, h, { duration, xOffset, yOffset, disposalMode })` | | `Frame.from(image, duration, x, y, disposal)` | `Frame.from(image, { duration, xOffset, yOffset, disposalMode })` | | `new Animation(frames, loopCount)` | `new Animation(frames, { loopCount })` | @@ -273,6 +296,40 @@ object under the names of their options. The options of each method are on its o `encodePNG()` also throws when the metadata is passed without `metadata`. In 3.x, `image.encodePNG({ author })` was read as metadata; in 4.0 it would be ignored, so it throws a `TypeError` that names `{ metadata }`. +## Renamed and reshaped APIs + +These changed in 4.0 without a deprecation in 3.x. Most are compile errors in TypeScript; in JavaScript, and for +options objects that aren't written inline, an old option key is ignored without an error (`rotate(45, { resize: +false })` grows the image again), so search for them too. + +| 3.x | 4.0 | +| --- | --- | +| `image.rotate(angle, { resize: false })` | `image.rotate(angle, { expand: false })`, like `transform()`, `outline()` and `dropShadow()` | +| `Image.renderSVG(svg, { size: 512, mode: 'width' })`, `{ mode: 'height' }` | `Image.renderSVG(svg, { width: 512 })`, `{ height: 512 }` | +| `Image.renderSVG(svg, { size: 2 })`, `{ size: 2, mode: 'scale' }` | `Image.renderSVG(svg, { scale: 2 })` | +| `SVGScaleModes`, `SVGScaleMode` | Removed, see the two rows above | +| `image.drawBox(x, y, w, h, color)` | `image.composite(new Image(w, h).fill(color), { x, y })`. It blends a translucent color over the image; `drawBox()` replaced the pixels | +| `image.drawCircle(x, y, r, color)` | `image.composite(new Image(2 * r, 2 * r).fill(color).cropCircle(), { x: x - r, y: y - r })` | +| `image.greyscale()` | `image.grayscale()` (the whole API spells American English) | +| `new TextLayout(options)`, `layout: new TextLayout(options)` | `layout: options`, a plain object ({@link TextLayoutOptions}); the class isn't exported | +| `AnimationDecodeOptions` (type) | `DecodeOptions`, which `Image.decode()`, `Animation.decode()` and `decode()` all take | +| `EncodeOptions` (type) as the options of `encodeTGA()` | `TGAEncodeOptions`. `EncodeOptions` is now the `metadata` that all encoder options extend | +| `new GainMap(image, { gainMapMin, gainMapMax })`, `map.gainMapMin`, `map.gainMapMax` | `new GainMap(image, { min, max })`, `map.min`, `map.max` | +| `setPixelAt(x, y, pixelColor)`, `lightness(value)`, `saturation(value)` (parameter names only) | `setPixelAt(x, y, color)`, `lightness(factor)`, `saturation(factor)` | + +Values that were only checked when they were used are now checked when they are set: `frame.duration`, +`frame.xOffset` and `frame.yOffset` (a finite number, a duration from 0), and the parameters of a `GainMap` (finite, +1 or 3 values per channel, a gamma above 0, `hdrCapacityMin` from 0, `useBaseColorSpace` a boolean). An invalid value +throws a `RangeError` (a `TypeError` for `useBaseColorSpace`) and the old value stays. + +An `Animation` now has a canvas size of its own: `animation.width` and `animation.height` are the decoded file's, the +one given as `new Animation(frames, { width, height })`, or the one `resize()`, `crop()`, `contain()` and `cover()` set. +Without one, they are the frames' right and bottom edge, as in 3.x. Frames, or the parts of them, outside the canvas +aren't encoded. + +The encoders return `Promise>`, so their output goes into `new Blob()`, `new Response()` and +`crypto.subtle` without a cast. This needs TypeScript 5.7 or newer. + ## Methods that take options refuse other arguments A method that takes options now throws a `TypeError` when its argument is anything other than an object (or left out). @@ -284,7 +341,7 @@ TypeError: cropCircle() takes an options object, like cropCircle({ size: 'cover' This is how the positional calls you missed show up at runtime. It applies to every method that took options in 3.x, including `cropCircle()`, `Image.renderText()`, `quantize()`, `transform()`, `dropShadow()`, `outline()`, the -gradients, `Image.decode()`, the encoders, `Animation.decode()`, `new GainMap()` and `new TextLayout()`. +gradients, `Image.decode()`, the encoders, `Animation.decode()`, `new GainMap()` and the `layout` of `Image.renderText()`. ## Checklist @@ -320,6 +377,10 @@ grep -rnE '\.(composite|rotate|sharpen|dominantColor|roundCorners|fisheye|trim|c grep -rnE 'renderSVG\(|renderText\(|cropCircle\(' src grep -rnE '\.(red|green|blue|opacity|lightness|saturation)\([^)]*,' src +# renamed and reshaped in 4.0 +grep -rnE '\.(contain|fit)\(|greyscale|drawBox|drawCircle|SVGScaleMode|TextLayout|AnimationDecodeOptions|gainMapM(in|ax)' src +grep -rnE 'fromHSLA?\(|toHSLA\(|rotate\([^)]*resize|renderSVG\([^)]*(size|mode)' src + # GainMap grep -rnE 'gainMap\.encode\(|\.encode\(\s*[0-9]' src ``` @@ -327,5 +388,6 @@ grep -rnE 'gainMap\.encode\(|\.encode\(\s*[0-9]' src Also search for the variables that hold animations, and for `[0]`, `.map(`, `.filter(` and `.forEach(` on them: the search can't tell an animation from an array, so check the type of each receiver. -Then compile with `tsc`, and run the tests that cover your decode and encode paths. The three behaviour changes need -runtime checks: a file with several frames, a one-frame GIF, and any `loopCount: -1` in your code. +Then compile with `tsc`, and run the tests that cover your decode and encode paths. The five behavior changes need +runtime checks: a file with several frames, a one-frame GIF, any `loopCount: -1`, every `contain()` and every HSL +hue in your code. From fb31240aa953ab14b957ccb00ad564afc4889ff9 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:36:24 +0000 Subject: [PATCH 053/188] remove the checks for 3.x calls --- src/image.ts | 15 ++------------- src/text-layout.ts | 4 ++-- src/util/options.ts | 17 ++++------------- tests/loop-count.js | 2 +- tests/misc.js | 9 --------- tests/options-objects-all.js | 2 +- tests/options-objects.js | 4 ++-- tests/options-only.js | 25 +------------------------ tests/regressions.js | 4 ++-- 9 files changed, 15 insertions(+), 67 deletions(-) diff --git a/src/image.ts b/src/image.ts index ad005d1a..8b16a099 100644 --- a/src/image.ts +++ b/src/image.ts @@ -33,7 +33,7 @@ import { resample, BOX, TRIANGLE, CUBIC, type Region } from './util/resample.ts' import { rotateBilinear } from './util/rotate.ts'; import { gaussianBlur, boxBlurRGBA, premultiply, unpremultiply } from './util/blur.ts'; import { Colors, wrapHue, hue2rgb } from './colors.ts'; -import { isOptions, checkOptions, checkFactorOnly, finite, color as checkColor, quality as checkQuality, effort as checkEffort, compressionLevel } from './util/options.ts'; +import { isOptions, checkOptions, finite, color as checkColor, quality as checkQuality, effort as checkEffort, compressionLevel } from './util/options.ts'; import { MAX_PIXELS, decoding } from './util/limits.ts'; import { GainMap } from './gainmap.ts'; import * as gain from './util/gain.ts'; @@ -1299,7 +1299,6 @@ export class Image { * @param opacity The factor to multiply the opacity with (0..1 lowers it, >1 raises it, results are clamped) */ opacity(opacity: number): this { - checkFactorOnly('opacity', arguments.length); opacity = finite(opacity, 'opacity value'); if (opacity < 0) throw new RangeError('Invalid opacity value'); @@ -1314,7 +1313,6 @@ export class Image { * @param factor The factor to multiply the channel with (>= 0, results are clamped) */ red(factor: number): this { - checkFactorOnly('red', arguments.length); if (this.__gain_map__) return this.__recolor__(image => image.red(factor)); factor = finite(factor, 'red factor'); if (factor < 0) @@ -1330,7 +1328,6 @@ export class Image { * @param factor The factor to multiply the channel with (>= 0, results are clamped) */ green(factor: number): this { - checkFactorOnly('green', arguments.length); if (this.__gain_map__) return this.__recolor__(image => image.green(factor)); factor = finite(factor, 'green factor'); if (factor < 0) @@ -1346,7 +1343,6 @@ export class Image { * @param factor The factor to multiply the channel with (>= 0, results are clamped) */ blue(factor: number): this { - checkFactorOnly('blue', arguments.length); if (this.__gain_map__) return this.__recolor__(image => image.blue(factor)); factor = finite(factor, 'blue factor'); if (factor < 0) @@ -1371,7 +1367,6 @@ export class Image { * @param factor The factor to multiply the lightness with (>= 0, results are clamped) */ lightness(factor: number): this { - checkFactorOnly('lightness', arguments.length); if (this.__gain_map__) return this.__recolor__(image => image.lightness(factor)); factor = finite(factor, 'lightness factor'); if (factor < 0) @@ -1385,7 +1380,6 @@ export class Image { * @param factor The factor to multiply the saturation with (>= 0, results are clamped) */ saturation(factor: number): this { - checkFactorOnly('saturation', arguments.length); if (this.__gain_map__) return this.__recolor__(image => image.saturation(factor), true); factor = finite(factor, 'saturation factor'); if (factor < 0) @@ -2469,9 +2463,6 @@ export class Image { encodePNG(options?: PNGEncodeOptions): Promise>; async encodePNG(options: PNGEncodeOptions = {}): Promise> { checkOptions(options, 'encodePNG({ compression: 9, metadata: { author: \'me\' } })'); - // the metadata of 3.x passed as the only argument would be ignored: it has to go in { metadata } - for (const key of Object.keys(options)) - if (key !== 'compression' && key !== 'metadata') throw new TypeError(`encodePNG() takes the metadata in { metadata }, like encodePNG({ metadata: { ${key}: ... } }), not ${key} at the top level`); const { compression = 4, metadata = {} } = options; return png.encodeRGBA(this.__bytes__(), this.width, this.height, compressionLevel(compression), pngText(resolveMetadata(metadata))); @@ -2937,9 +2928,7 @@ interface HSLA { export function resizeArguments(widthOrOptions: number | ResizeToOptions, height?: number, options?: ResizeOptions): [width: number | undefined, height: number | undefined, options: ResizeOptions | undefined, fit: 'fill' | 'inside'] { if (!isOptions(widthOrOptions)) { checkOptions(options, 'resize(width, height, { mode: \'area\' })'); - // without a height, the aspect ratio would be kept silently (3.x refused that call) - if (height === undefined) throw new TypeError(`resize() needs a height or the options form, like resize({ width: ${widthOrOptions} }) to keep the aspect ratio`); - return [finite(widthOrOptions, 'width'), finite(height, 'height'), options, 'fill']; + return [finite(widthOrOptions, 'width'), finite(height as number, 'height'), options, 'fill']; } const { width, height: optionsHeight, fit = 'fill', ...rest } = widthOrOptions; if (width === undefined && optionsHeight === undefined) throw new TypeError('resize() needs a width, a height or both'); diff --git a/src/text-layout.ts b/src/text-layout.ts index 33d3d16d..1288299d 100644 --- a/src/text-layout.ts +++ b/src/text-layout.ts @@ -66,11 +66,11 @@ export class TextLayout { throw new RangeError('Invalid wrapStyle'); if (horizontalAlign !== undefined) { - if (!HORIZONTAL.includes(horizontalAlign)) throw new TypeError(`text alignments only take the values of their own axis: horizontalAlign takes 'left', 'center' or 'right', not '${horizontalAlign}'`); + if (!HORIZONTAL.includes(horizontalAlign)) throw new TypeError(`horizontalAlign must be 'left', 'center' or 'right', not '${horizontalAlign}'`); this.horizontalAlign = horizontalAlign; } if (verticalAlign !== undefined) { - if (!VERTICAL.includes(verticalAlign)) throw new TypeError(`text alignments only take the values of their own axis: verticalAlign takes 'top', 'middle' or 'bottom', not '${verticalAlign}'`); + if (!VERTICAL.includes(verticalAlign)) throw new TypeError(`verticalAlign must be 'top', 'middle' or 'bottom', not '${verticalAlign}'`); this.verticalAlign = verticalAlign; } diff --git a/src/util/options.ts b/src/util/options.ts index 82d4706d..2ac8f7ad 100644 --- a/src/util/options.ts +++ b/src/util/options.ts @@ -4,22 +4,13 @@ export function isOptions(value: T): value is Extract { } /** - * Checks that the options argument of a method is left out or an object. A method that only takes options (4.0 removed - * the positional forms) throws a TypeError for anything else instead of silently ignoring it, which names the form that - * works: `usage` is e.g. `cropCircle({ size: 'cover' })` + * Checks that the options argument of a method is left out or an object: anything else throws a TypeError instead of + * being ignored silently, naming the form that works (`usage`, e.g. `cropCircle({ size: 'cover' })`) */ export function checkOptions(value: unknown, usage: string): void { if (value !== undefined && !isOptions(value)) throw new TypeError(`${usage.split('(')[0]}() takes an options object, like ${usage}, not ${value === null ? 'null' : typeof value}`); } -/** - * The channel methods take a factor only: 3.x's `absolute` argument, a second one, would be a silent scale now, so more - * than one argument throws (before the image is touched). `method` is the name of the method - */ -export function checkFactorOnly(method: string, count: number): void { - if (count > 1) throw new TypeError(`channel methods reject the removed absolute argument: use ${method}(factor) to scale, or recolor() for a fixed color`); -} - /** * A number argument, converted like arithmetic does (numeric strings keep working); NaN and the infinities throw * a RangeError naming the argument @@ -32,12 +23,12 @@ export function finite(value: number, name: string): number { /** * A loop count: `Infinity` (loop forever) or an integer from 0 (0 plays once, 1 plays twice, ...). Anything else that is - * a number throws a RangeError (`-1`, which 3.x meant as forever, says so), anything that isn't one a TypeError + * a number throws a RangeError, anything that isn't one a TypeError */ export function loopCount(value: unknown, name = 'loop count'): number { if (typeof value !== 'number') throw new TypeError(`${name} must be a number (Infinity or an integer from 0), not ${value === null ? 'null' : typeof value}`); if (value !== Infinity && (!Number.isInteger(value) || value < 0)) - throw new RangeError(`${name} must be Infinity or an integer from 0, not ${value}${value === -1 ? ' (use Infinity to loop forever)' : ''}`); + throw new RangeError(`${name} must be Infinity or an integer from 0, not ${value}`); return value; } diff --git a/tests/loop-count.js b/tests/loop-count.js index 30726955..624bdc32 100644 --- a/tests/loop-count.js +++ b/tests/loop-count.js @@ -112,7 +112,7 @@ const netscape = bytes => { throwsA(() => Animation.webpEncoder(4, 4, {loopCount: value}), expected, `webpEncoder with ${label}`); } - // -1 meant forever in 3.x: it says so in the message, and it is refused like any other negative number + // negative counts are refused, the message names the valid values throwsA(() => new Animation(frames(), {loopCount: -1}), RangeError, 'loopCount -1'); try { new Animation(frames(), {loopCount: -1}); } catch (error) { if (!/Infinity/.test(error.message)) panic(`-1: the message should name Infinity, got ${error.message}`); diff --git a/tests/misc.js b/tests/misc.js index 908512ed..e838a14f 100644 --- a/tests/misc.js +++ b/tests/misc.js @@ -146,15 +146,6 @@ if (Colors.fromHSLA(0, 0, 1, 0xff) !== 0xffffffff) panic(`${func.name} failed with value ${JSON.stringify(value)}`); } catch { } - - // the channel methods take a factor only: the 3.x absolute argument throws - const toReject = [[1, true], [1, false], [0, true], [0, false]]; - for (const func of funcs) - for (const value of toReject) { - let error = null; - try { func.call(image, ...value); } catch (e) { error = e; } - if (!(error instanceof TypeError)) panic(`${func.name} with a second argument should throw a TypeError, got ${error}`); - } } { diff --git a/tests/options-objects-all.js b/tests/options-objects-all.js index 42e37cab..2b628bcd 100644 --- a/tests/options-objects-all.js +++ b/tests/options-objects-all.js @@ -6,7 +6,7 @@ const panic = message => { }; // the options of every method that takes them must be an object (or left out): a stray number, string or boolean is a -// call in a form that doesn't exist (the 3.x positional forms, or a typo), so it throws instead of being ignored +// call in a form that doesn't exist, so it throws instead of being ignored (async () => { const photo = new Image(16, 10).fill((x, y) => 0x336699ff + x * 0x01000000 + y * 0x00010000 >>> 0); const frame = Frame.from(photo); diff --git a/tests/options-objects.js b/tests/options-objects.js index e374a362..d0769f23 100644 --- a/tests/options-objects.js +++ b/tests/options-objects.js @@ -16,7 +16,7 @@ const throwsA = async (fn, type, label) => { const font = await fs.readFile('./tests/fonts/opensans bold.ttf'); const photo = new Image(20, 12).fill((x, y) => 0x336699ff + x * 0x01000000 + y * 0x00010000 >>> 0); - // methods that only take options refuse anything else (the positional forms of 3.x are gone), instead of ignoring it + // methods that only take options refuse anything else, instead of ignoring it for (const [label, bad] of Object.entries({'true': true, 'false': false, 'a number': 0.5, 'a string': 'cover', 'null': null, 'a typed array': new Uint8Array(2)})) { const image = photo.clone(); const error = await throwsA(() => image.cropCircle(bad), TypeError, `cropCircle(${label})`); @@ -26,7 +26,7 @@ const throwsA = async (fn, type, label) => { for (const fine of [undefined, {}, {size: 'cover'}, {size: 'contain', feathering: 0.1}]) photo.clone().cropCircle(fine); await throwsA(() => photo.clone().cropCircle({size: 'huge'}), TypeError, 'cropCircle with an unknown size'); - // renderText(text, { font, size, ... }) is the only form: the 3.x forms with the font first fail clearly + // renderText(text, { font, size, ... }) is the only form: the font first fails clearly const ok = await Image.renderText('Hi', {font, size: 24}); if (ok.width < 4 || ok.height < 4) panic(`renderText: ${ok.width}x${ok.height}`); for (const [label, call] of Object.entries({ diff --git a/tests/options-only.js b/tests/options-only.js index fca401cf..f900a392 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -5,7 +5,7 @@ const panic = message => { process.exit(1); }; -// the 3.x positional forms of 4.0 methods throw a TypeError (and leave the image as it was) +// methods that take options throw a TypeError for anything else than an options object (and leave the image as it was) const rejects = async (call, label) => { let error = null; try { await call(); } catch (e) { error = e; } @@ -84,7 +84,6 @@ const sameImage = (image, reference, label) => { for (const [label, call] of Object.entries({ 'encodePNG(1)': () => photo.encodePNG(1), 'encodePNG(1, { author })': () => photo.encodePNG(1, {author: 'me'}), - 'encodePNG({ author }) (3.x metadata)': () => photo.encodePNG({author: 'me'}), 'encodeJPEG(80)': () => photo.encodeJPEG(80), 'encodeGIF(10)': () => makeAnimation().encodeGIF(10), 'encodeAPNG(9)': () => makeAnimation().encodeAPNG(9), @@ -133,28 +132,6 @@ const sameImage = (image, reference, label) => { if (!/takes an options object/.test(error.message)) panic(`${label}: the message should name the options form, got ${error.message}`); } - // the channel methods take a factor only: a second argument (3.x's absolute) throws before the image is touched - for (const method of ['opacity', 'red', 'green', 'blue', 'lightness', 'saturation']) { - for (const second of [true, {absolute: true}]) { - const image = photo.clone(); - const before = Buffer.from(image.bitmap); - const error = await rejects(() => image[method](0.5, second), `${method}(0.5, ${JSON.stringify(second)})`); - if (!/channel methods reject the removed absolute argument/.test(error.message)) panic(`${method}: the message should name the replacement, got ${error.message}`); - if (!Buffer.from(image.bitmap).equals(before)) panic(`${method}(0.5, ${JSON.stringify(second)}) changed the image before throwing`); - } - } - if (new Image(1, 1).fill(0xc80000ff).red(0.5).getPixelAt(0, 0) !== 0x640000ff) panic('red(0.5) should scale the red channel'); - - // the positional resize needs a height (3.x refused resize(100)): the message names the options form - for (const [label, call] of Object.entries({ - 'image.resize(100)': () => photo.clone().resize(100), - 'image.resize(100, undefined)': () => photo.clone().resize(100, undefined), - 'frame.resize(100)': () => new Frame(8, 4).resize(100), - 'animation.resize(100)': () => new Animation([new Frame(8, 4)]).resize(100) - })) { - const error = await rejects(call, label); - if (!/resize\(\) needs a height or the options form, like resize\(\{ width: 100 \}\)/.test(error.message)) panic(`${label}: the message should name resize({ width: 100 }), got ${error.message}`); - } let nanError = null; try { photo.clone().resize(NaN, 6); } catch (e) { nanError = e; } if (!(nanError instanceof RangeError)) panic(`resize(NaN, 6) should throw a RangeError, got ${nanError}`); diff --git a/tests/regressions.js b/tests/regressions.js index 79e23cfa..45d9d431 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -132,14 +132,14 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); assert(alpha(0, 0) < 64 && alpha(3, 0) === 255 && alpha(0, 1) > 0 && alpha(0, 1) < 255, 'roundCorners should antialias the arc'); } - // text alignments only take the values of their own axis + // text alignments only take the values of their own axis (the message names them) { const font = require('fs').readFileSync('./tests/fonts/opensans bold.ttf'); await Image.renderText('a', {font, size: 12, layout: {horizontalAlign: 'right', verticalAlign: 'middle'}}); for (const options of [{verticalAlign: 'center', horizontalAlign: 'bottom'}, {horizontalAlign: 'top'}, {verticalAlign: 'left'}, {horizontalAlign: 'middle'}]) { let error = null; try { await Image.renderText('a', {font, size: 12, layout: options}); } catch (e) { error = e; } - assert(error instanceof TypeError && error.message.includes('text alignments only take the values of their own axis'), `layout ${JSON.stringify(options)} should throw a TypeError, got ${error}`); + assert(error instanceof TypeError && /must be '(left|top)'/.test(error.message), `layout ${JSON.stringify(options)} should throw a TypeError, got ${error}`); } } From 79b048d1c0534c3f5d6481ea5134584695ce950d Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:41:17 +0000 Subject: [PATCH 054/188] replace the mode constant classes with plain strings --- src/animation.ts | 8 +++---- src/frame.ts | 27 +++++++--------------- src/image.ts | 53 ++++++++++++++----------------------------- src/index.ts | 4 ++-- tests/gif.js | 6 ++--- tests/options-only.js | 18 +++++++-------- tests/regressions.js | 18 +++++++-------- tests/resize.js | 8 +++---- 8 files changed, 55 insertions(+), 87 deletions(-) diff --git a/src/animation.ts b/src/animation.ts index cec048e7..9a9cb835 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -7,7 +7,7 @@ import { availableThreads } from './util/threads.ts'; import { BinaryData, view } from './util/mem.ts'; import { GainMap } from './gainmap.ts'; import { Image, DecodeOptions, ContainOptions, ResizeOptions, ResizeToOptions, resizeArguments, insideSize } from './image.ts'; -import { Frame, FrameDisposalModes } from './frame.ts'; +import { Frame } from './frame.ts'; import { ImageType } from './image-type.ts'; import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts'; import { Metadata, resolveMetadata, pngText, exifData, gifComments } from './metadata.ts'; @@ -18,7 +18,7 @@ import { AnimationEncoder } from './animation-encoder.ts'; // frames decoded fully composited, as full canvas frames function canvasFrames(width: number, height: number, frames: Array<{ pixels: Uint8Array | Uint8ClampedArray, duration: number }>): Frame[] { return frames.map(({ pixels, duration }) => { - const frame = new Frame(width, height, { duration, disposalMode: FrameDisposalModes.BACKGROUND }); + const frame = new Frame(width, height, { duration, disposalMode: 'background' }); // the composited frames are copies already, the frame can keep them frame.bitmap = pixels; return frame; @@ -290,7 +290,7 @@ export class Animation { if (backgroundColor !== 0) { const composited = png.compositeAnimation({ width: boxWidth, height: boxHeight, frames: this.__apngFrames__() }); this.frames = composited.map(({ pixels, duration }) => { - const frame = new Frame(boxWidth, boxHeight, { duration, disposalMode: FrameDisposalModes.BACKGROUND }).fill(backgroundColor); + const frame = new Frame(boxWidth, boxHeight, { duration, disposalMode: 'background' }).fill(backgroundColor); const content = new Image(boxWidth, boxHeight); content.bitmap = pixels; return frame.composite(content); @@ -568,7 +568,7 @@ export class Animation { let loops = Infinity, icc: Uint8Array | undefined, space: jxl.ColorSpace | undefined; let orientation = 1; for await (const decoded of jxl.frames(data, maxPixels, toneMapping, threads)) { - const frame = new Frame(decoded.width, decoded.height, { duration: decoded.duration, disposalMode: FrameDisposalModes.BACKGROUND }); + const frame = new Frame(decoded.width, decoded.height, { duration: decoded.duration, disposalMode: 'background' }); frame.bitmap = decoded.pixels; frames.push(frame); orientation = decoded.orientation; diff --git a/src/frame.ts b/src/frame.ts index 6f6a9a6f..0f00215f 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -1,7 +1,13 @@ import { Image, ResizeOptions, ResizeToOptions, TrimOptions, resizeArguments } from './image.ts'; import { checkOptions, finite } from './util/options.ts'; -/** Names of the frame disposal modes, see {@link Frame.disposalMode} */ +/** + * What happens to a frame's area before the next frame is drawn, see {@link Frame.disposalMode}: + * - `'keep'` (the default): the frame stays, the next one is drawn over it + * - `'background'`: the frame's area is cleared to transparent + * - `'previous'`: the frame's area goes back to what it was before the frame + * - `'any'`: the decoder decides (in practice like `'keep'`) + */ export type FrameDisposalModeName = 'any' | 'keep' | 'previous' | 'background'; // the ids the library stores the modes as (0 any, 1 keep, 2 previous, 3 background, like GIFs) @@ -9,23 +15,6 @@ type DisposalId = 0 | 1 | 2 | 3; const DISPOSAL_MODES: readonly FrameDisposalModeName[] = ['any', 'keep', 'previous', 'background']; -/** What happens to a frame's area before the next frame is drawn, see {@link Frame.disposalMode} */ -export class FrameDisposalModes { - /** The decoder decides (in practice like {@link FrameDisposalModes.KEEP}) */ - static readonly ANY = 'any'; - - /** The frame stays, the next one is drawn over it (the default) */ - static readonly KEEP = 'keep'; - - /** The frame's area goes back to what it was before the frame */ - static readonly PREVIOUS = 'previous'; - - /** The frame's area is cleared to transparent */ - static readonly BACKGROUND = 'background'; - - private constructor() {} -} - /** Options of {@link Frame} and {@link Frame.from} */ export interface FrameOptions { /** The frame's duration in milliseconds. Default: `100` */ @@ -76,7 +65,7 @@ export class Frame extends Image { constructor(width: number, height: number, options?: FrameOptions); constructor(width: number, height: number, options: FrameOptions = {}) { checkOptions(options, 'Frame(width, height, { duration: 100 })'); - const { duration = 100, xOffset: x = 0, yOffset: y = 0, disposalMode: disposal = FrameDisposalModes.KEEP } = options; + const { duration = 100, xOffset: x = 0, yOffset: y = 0, disposalMode: disposal = 'keep' } = options; if (finite(duration, 'frame duration') < 0) throw new RangeError('Invalid frame duration'); finite(x, 'x offset'); diff --git a/src/image.ts b/src/image.ts index 8b16a099..86ad4532 100644 --- a/src/image.ts +++ b/src/image.ts @@ -47,36 +47,17 @@ export type Color = number; /** Called with a pixel's (0-based) coordinates, returns its color */ export type ColorFunction = (x: number, y: number) => Color; -/** Resampling filters for {@link Image.resize} and the other scaling functions, see {@link ResizeModes} */ -export type ResizeMode = 'nearest-neighbor' | 'bilinear' | 'bicubic' | 'area'; - /** - * The resampling filters of {@link Image.resize} and the other scaling functions: - * `image.resize({ width: 256, mode: ResizeModes.BICUBIC })` + * The resampling filters of {@link Image.resize} and the other scaling functions (`image.resize({ width: 256, mode: + * 'bicubic' })`): + * - `'nearest-neighbor'`: each output pixel takes the nearest source pixel (hard pixels, for pixel art) + * - `'bilinear'` (the default): when shrinking, the filter widens with the scale factor, so every source pixel + * contributes + * - `'bicubic'`: Catmull-Rom, sharper than bilinear, especially when enlarging + * - `'area'`: area averaging (box filter), every output pixel is the average of the source pixels it covers. Meant for + * shrinking */ -export class ResizeModes { - /** - * Use {@link https://en.wikipedia.org/wiki/Image_scaling#Nearest-neighbor_interpolation Nearest-neighbor} resizing - */ - static readonly NEAREST_NEIGHBOR = 'nearest-neighbor'; - - /** - * Bilinear resizing (the default). When shrinking, the filter widens with the scale factor, so every source pixel - * contributes - */ - static readonly BILINEAR = 'bilinear'; - - /** Bicubic (Catmull-Rom) resizing, sharper than bilinear, especially when enlarging */ - static readonly BICUBIC = 'bicubic'; - - /** - * Area averaging (box filter): every output pixel is the average of the source pixels it covers. Meant for - * shrinking - */ - static readonly AREA = 'area'; - - private constructor() {} -} +export type ResizeMode = 'nearest-neighbor' | 'bilinear' | 'bicubic' | 'area'; /** Blend modes of {@link Image.composite}, named like CSS' mix-blend-mode values */ export type BlendMode = 'normal' | 'multiply' | 'screen' | 'overlay' | 'darken' | 'lighten' | 'color-dodge' | 'color-burn' @@ -120,14 +101,14 @@ export interface ContainOptions extends ResizeOptions { /** Options of {@link Image.scale}, {@link Image.resize}, {@link Image.contain} and {@link Image.cover} */ export interface ResizeOptions { - /** The resizing mode. Default: {@link ResizeModes.BILINEAR} */ + /** The resizing mode. Default: `'bilinear'` */ mode?: ResizeMode; /** * Whether to round to 8 bits between the two passes, so two channels share the multiplications of both: about * 15-25% faster for opaque images, with 10-25% of the channel values a step off the precise result (instead of * about 1%, never more than 1). Transparent images are premultiplied to 8 bits like browsers do, which makes them * about twice as fast: what shows once they're composited differs by up to about 2 steps, pixels that are almost - * transparent keep less of their color. Only affects {@link ResizeModes.BILINEAR} and {@link ResizeModes.AREA}, + * transparent keep less of their color. Only affects `'bilinear'` and `'area'`, * down to about a third of the size. Default: `true` */ fast?: boolean; @@ -937,7 +918,7 @@ export class Image { * @internal * @ignore */ - protected __scale__(factor: number, mode: ResizeMode = ResizeModes.BILINEAR, fast = true): Image { + protected __scale__(factor: number, mode: ResizeMode = 'bilinear', fast = true): Image { if (factor === 1) return this; return this.__resize__(this.width * factor, this.height * factor, mode, fast); } @@ -1017,7 +998,7 @@ export class Image { const scale = Math.max(width / this.width, height / this.height); const resizedWidth = Math.max(width, Math.round(this.width * scale)), resizedHeight = Math.max(height, Math.round(this.height * scale)); // only the part the crop keeps is resampled (the same pixels as resizing the whole image, then cropping) - if (resizeOpts.mode !== ResizeModes.NEAREST_NEIGHBOR && width >= 1 && height >= 1) { + if (resizeOpts.mode !== 'nearest-neighbor' && width >= 1 && height >= 1) { const region = { x: ~~((resizedWidth - width) / 2), y: ~~((resizedHeight - height) / 2), width, height }; this.__rescaled__?.(resizedWidth, resizedHeight); const fx = resizedWidth / this.width, fy = resizedHeight / this.height; @@ -1046,7 +1027,7 @@ export class Image { * @internal * @ignore */ - protected __resize__(width: number, height: number, mode: ResizeMode = ResizeModes.BILINEAR, fast = true, region?: Region): Image { + protected __resize__(width: number, height: number, mode: ResizeMode = 'bilinear', fast = true, region?: Region): Image { width = Math.floor(width); height = Math.floor(height); if (width < 1) @@ -1054,9 +1035,9 @@ export class Image { if (height < 1) throw new RangeError('Image has to be at least 1 pixel high'); - if (mode === ResizeModes.NEAREST_NEIGHBOR) return this.__resize_nearest_neighbor__(width, height); + if (mode === 'nearest-neighbor') return this.__resize_nearest_neighbor__(width, height); - const filter = mode === ResizeModes.BILINEAR ? TRIANGLE : mode === ResizeModes.BICUBIC ? CUBIC : mode === ResizeModes.AREA ? BOX : null; + const filter = mode === 'bilinear' ? TRIANGLE : mode === 'bicubic' ? CUBIC : mode === 'area' ? BOX : null; if (!filter) throw new Error('Invalid resize mode'); const image = new Image(region?.width ?? width, region?.height ?? height); @@ -2948,7 +2929,7 @@ export function insideSize(width: number, height: number, boxWidth: number, boxH } function resizeOptions(options?: ResizeOptions): { mode: ResizeMode, fast: boolean } { - return { mode: options?.mode ?? ResizeModes.BILINEAR, fast: options?.fast ?? true }; + return { mode: options?.mode ?? 'bilinear', fast: options?.fast ?? true }; } // separable blend functions of normalized channel values (W3C Compositing and Blending Level 1) diff --git a/src/index.ts b/src/index.ts index d66e4363..3dabd0af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,10 +8,10 @@ import * as webp from './codecs/webp.ts'; import { view } from './util/mem.ts'; import { ImageType } from './image-type.ts'; -export { Image, ResizeModes } from './image.ts'; +export { Image } from './image.ts'; export { Colors } from './colors.ts'; export type { Color, ColorFunction, ResizeMode, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, ContainOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, TGAEncodeOptions, GainMapEmbedOptions, SVGOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts'; -export { Frame, FrameDisposalModes } from './frame.ts'; +export { Frame } from './frame.ts'; export type { FrameDisposalModeName, FrameOptions } from './frame.ts'; export { GainMap } from './gainmap.ts'; export type { GainMapOptions, GainMapEncodeOptions } from './gainmap.ts'; diff --git a/tests/gif.js b/tests/gif.js index ab868c19..9133053f 100644 --- a/tests/gif.js +++ b/tests/gif.js @@ -1,6 +1,6 @@ const fs = require('fs').promises; const crypto = require('crypto'); -const {Image, Frame, FrameDisposalModes, Animation, Colors} = require('..'); +const {Image, Frame, Animation, Colors} = require('..'); const panic = message => { console.error(message); @@ -59,9 +59,9 @@ const panic = message => { const background = new Frame(4, 4, {duration: 100}); background.fill(black); - const dot = new Frame(1, 1, {duration: 100, xOffset: 1, yOffset: 1, disposalMode: FrameDisposalModes.PREVIOUS}); + const dot = new Frame(1, 1, {duration: 100, xOffset: 1, yOffset: 1, disposalMode: 'previous'}); dot.fill(white); - const hole = new Frame(2, 2, {duration: 100, xOffset: 2, yOffset: 2, disposalMode: FrameDisposalModes.BACKGROUND}); + const hole = new Frame(2, 2, {duration: 100, xOffset: 2, yOffset: 2, disposalMode: 'background'}); hole.fill((x, y) => x === y ? white : 0); const last = new Frame(1, 1, {duration: 100, xOffset: 0, yOffset: 0}); last.fill(white); diff --git a/tests/options-only.js b/tests/options-only.js index f900a392..0cfbe9ed 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -1,4 +1,4 @@ -const {Image, Frame, Animation, GainMap, ResizeModes, decode} = require('..'); +const {Image, Frame, Animation, GainMap, decode} = require('..'); const panic = message => { console.error(message); @@ -22,7 +22,7 @@ const sameImage = (image, reference, label) => { const photo = new Image(20, 12).fill((x, y) => 0x336699ff + x * 0x01000000 + y * 0x00010000 >>> 0); // resize, scale, contain and cover take the resizing mode in the options only - const modes = {'a mode name': ResizeModes.AREA, 'a string': 'bilinear', 'an unknown mode': 'Area', 'null': null}; + const modes = {'a mode name': 'area', 'a string': 'bilinear', 'an unknown mode': 'Area', 'null': null}; for (const [label, mode] of Object.entries(modes)) { for (const method of ['resize', 'contain', 'cover']) { const image = photo.clone(); @@ -137,13 +137,13 @@ const sameImage = (image, reference, label) => { if (!(nanError instanceof RangeError)) panic(`resize(NaN, 6) should throw a RangeError, got ${nanError}`); // the options form still resizes; undefined stays fine - if (photo.clone().resize(10, 6, {mode: ResizeModes.AREA}).width !== 10) panic('resize(10, 6, { mode })'); + if (photo.clone().resize(10, 6, {mode: 'area'}).width !== 10) panic('resize(10, 6, { mode })'); if (photo.clone().resize(10, 6).height !== 6) panic('resize(10, 6)'); if (photo.clone().resize(10, 6, undefined).height !== 6) panic('resize(10, 6, undefined)'); - if (photo.clone().scale(0.5, {mode: ResizeModes.AREA}).width !== 10) panic('scale(0.5, { mode })'); - if (photo.clone().resize({width: 10, height: 10, fit: 'inside', mode: ResizeModes.AREA}).width !== 10) panic('resize({ width, height, fit: \'inside\', mode })'); - if (photo.clone().contain(10, 10, {mode: ResizeModes.AREA}).height !== 10) panic('contain(10, 10, { mode })'); - if (photo.clone().cover(10, 10, {mode: ResizeModes.AREA}).width !== 10) panic('cover(10, 10, { mode })'); - if (new Frame(20, 12).resize(10, 6, {mode: ResizeModes.AREA}).width !== 10) panic('Frame.resize(10, 6, { mode })'); - if (new Animation([new Frame(20, 12)]).resize(10, 6, {mode: ResizeModes.AREA}).frames[0].width !== 10) panic('Animation.resize(10, 6, { mode })'); + if (photo.clone().scale(0.5, {mode: 'area'}).width !== 10) panic('scale(0.5, { mode })'); + if (photo.clone().resize({width: 10, height: 10, fit: 'inside', mode: 'area'}).width !== 10) panic('resize({ width, height, fit: \'inside\', mode })'); + if (photo.clone().contain(10, 10, {mode: 'area'}).height !== 10) panic('contain(10, 10, { mode })'); + if (photo.clone().cover(10, 10, {mode: 'area'}).width !== 10) panic('cover(10, 10, { mode })'); + if (new Frame(20, 12).resize(10, 6, {mode: 'area'}).width !== 10) panic('Frame.resize(10, 6, { mode })'); + if (new Animation([new Frame(20, 12)]).resize(10, 6, {mode: 'area'}).frames[0].width !== 10) panic('Animation.resize(10, 6, { mode })'); })().catch(error => panic(error.stack || String(error))); diff --git a/tests/regressions.js b/tests/regressions.js index 45d9d431..4646aac1 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -1,5 +1,5 @@ const {Image, Frame, Animation} = require('..'); -const {ResizeModes, FrameDisposalModes, Colors} = require('..'); +const {Colors} = require('..'); const zlib = require('zlib'); // (encoders stamp the current time into the metadata: tests that compare two encodes freeze it, or they differ when a second ends in between) const withFrozenTime = async run => { @@ -82,7 +82,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // frames keep their metadata { - const frame = new Frame(10, 10, {duration: 500, xOffset: 2, yOffset: 4, disposalMode: FrameDisposalModes.BACKGROUND}); + const frame = new Frame(10, 10, {duration: 500, xOffset: 2, yOffset: 4, disposalMode: 'background'}); const clone = frame.clone(); assert(clone instanceof Frame && clone.duration === 500 && clone.xOffset === 2 && clone.disposalMode === 'background', 'Frame.clone lost metadata'); @@ -236,7 +236,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // resizing filters average instead of skipping pixels, in premultiplied alpha { const checker = new Image(64, 64).fill((x, y) => (x + y) & 1 ? 0xffffffff : 0x000000ff); - for (const mode of [ResizeModes.BILINEAR, ResizeModes.BICUBIC, ResizeModes.AREA]) { + for (const mode of ['bilinear', 'bicubic', 'area']) { const small = checker.clone().resize(8, 8, {mode}); for (const [x, y, color] of small.pixels()) assert(Math.abs((color >>> 24) - 128) <= 2, `${mode}: checkerboard should average to grey at ${x},${y}, got ${color >>> 24}`); @@ -246,7 +246,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const half = new Image(4, 1).fill((x) => x < 2 ? 0xff000000 : 0x0000ffff).resize(2, 1); assert(rgba(half, 0, 0).slice(0, 3).join() === '0,0,255', 'resize: transparent pixels bled into the result: ' + rgba(half, 0, 0)); - const nearest = new Image(4, 1).fill((x) => Colors.fromRGB(x, 0, 0)).resize(2, 1, {mode: ResizeModes.NEAREST_NEIGHBOR}); + const nearest = new Image(4, 1).fill((x) => Colors.fromRGB(x, 0, 0)).resize(2, 1, {mode: 'nearest-neighbor'}); assert(rgba(nearest, 0, 0)[0] === 1 && rgba(nearest, 1, 0)[0] === 3, 'nearest neighbor should sample pixel centers'); // (the upper case names of the removed Image.RESIZE_* constants are unknown modes too) @@ -342,7 +342,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // disposal modes read back as the names they are set with { - const frame = new Frame(1, 1, {duration: 100, xOffset: 0, yOffset: 0, disposalMode: FrameDisposalModes.PREVIOUS}); + const frame = new Frame(1, 1, {duration: 100, xOffset: 0, yOffset: 0, disposalMode: 'previous'}); assert(frame.disposalMode === 'previous', 'disposalMode should be a name, got ' + frame.disposalMode); // the numeric ids (0-3) are not accepted any more, the names are let threw = false; @@ -352,10 +352,8 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); assert(decoded.length === 2, 'gif with disposal modes should roundtrip'); } - // the constant classes hold the names of the modes (the deprecated Image/Frame constants were removed in 4.0) + // SVG sizes and resize modes { - assert(ResizeModes.NEAREST_NEIGHBOR === 'nearest-neighbor' && ResizeModes.BILINEAR === 'bilinear' && ResizeModes.BICUBIC === 'bicubic' && ResizeModes.AREA === 'area', 'ResizeModes values'); - assert(FrameDisposalModes.ANY === 'any' && FrameDisposalModes.KEEP === 'keep' && FrameDisposalModes.PREVIOUS === 'previous' && FrameDisposalModes.BACKGROUND === 'background', 'FrameDisposalModes values'); const svg = ''; for (const [size, width, height] of [[{scale: 2}, 20, 10], [{width: 20}, 20, 10], [{height: 20}, 40, 20], [{}, 10, 5]]) { const rendered = await Image.renderSVG(svg, size); @@ -364,7 +362,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); let twoSizes = null; try { await Image.renderSVG(svg, {width: 20, height: 20}); } catch (e) { twoSizes = e; } assert(twoSizes instanceof TypeError, 'renderSVG: width and height together should throw a TypeError'); - assert(new Image(8, 4).resize({width: 4, mode: ResizeModes.AREA}).height === 2, 'resize({width}) with a mode keeps the aspect ratio'); + assert(new Image(8, 4).resize({width: 4, mode: 'area'}).height === 2, 'resize({width}) with a mode keeps the aspect ratio'); const photo = new Image(9, 7).fill((x, y) => Colors.fromRGBA(x * 28, y * 36, (x ^ y) * 16, 255 - x * 10)); let threw = false; try { photo.clone().resize(4, 4, {mode: 'Area'}); } catch { threw = true; } @@ -378,7 +376,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const same = (a, b) => a.width === b.width && a.height === b.height && Buffer.from(a.bitmap).equals(Buffer.from(b.bitmap)); assert(same(photo.clone().resize({width: 6}), photo.clone().resize(6, 4)), 'resize({width})'); assert(photo.clone().resize({width: 6}).height === 4, 'resize({width}) keeps the aspect ratio'); - assert(same(photo.clone().resize({height: 4, mode: 'bicubic'}), photo.clone().resize(6, 4, {mode: ResizeModes.BICUBIC})), 'resize({height, mode})'); + assert(same(photo.clone().resize({height: 4, mode: 'bicubic'}), photo.clone().resize(6, 4, {mode: 'bicubic'})), 'resize({height, mode})'); assert(same(photo.clone().resize({width: 5, height: 9, fast: false}), photo.clone().resize(5, 9, {fast: false})), 'resize({width, height, fast})'); let threw = false; try { photo.clone().resize({}); } catch (e) { threw = e instanceof TypeError; } diff --git a/tests/resize.js b/tests/resize.js index 5310586f..be8ba219 100644 --- a/tests/resize.js +++ b/tests/resize.js @@ -1,5 +1,5 @@ const fs = require('fs').promises; -const {Image, ResizeModes} = require('..'); +const {Image} = require('..'); const samePixels = require('./util/pixels'); (async () => { const binary = await fs.readFile('./tests/targets/image.png'); @@ -24,11 +24,11 @@ const samePixels = require('./util/pixels'); spot.bitmap[(spot.height >> 1) * spot.width * 4 + 403] = 128; const sizes = [[0.5, 0.5], [1 / 3, 0.4], [2, 1.5], [1, 0.7]]; for (const source of [opaque, transparent, spot]) - for (const mode of [ResizeModes.BILINEAR, ResizeModes.AREA, ResizeModes.BICUBIC]) + for (const mode of ['bilinear', 'area', 'bicubic']) for (const [sx, sy] of sizes) { const width = Math.round(source.width * sx), height = Math.round(source.height * sy); const a = source.clone().resize(width, height, {mode, fast: false}).bitmap, b = source.clone().resize(width, height, {mode}).bitmap; - const limit = mode === ResizeModes.BICUBIC ? 0 : source === opaque ? 1 : 2.5; + const limit = mode === 'bicubic' ? 0 : source === opaque ? 1 : 2.5; let max = 0, differ = false; for (let i = 0; i < a.length; i++) { if (a[i] === b[i]) continue; @@ -36,7 +36,7 @@ const samePixels = require('./util/pixels'); const alpha = (i & 3) === 3, al = a[i | 3], bl = b[i | 3]; max = Math.max(max, source === opaque || alpha ? Math.abs(a[i] - b[i]) : Math.abs(a[i] * al - b[i] * bl) / 255); } - if (max > limit || mode !== ResizeModes.BICUBIC && sx === 0.5 && !differ) { + if (max > limit || mode !== 'bicubic' && sx === 0.5 && !differ) { console.error('fast resize', mode, sx, sy, source === opaque ? 'opaque' : source === spot ? 'spot' : 'transparent', max); process.exit(1); } From 9121d42be5a12d7b1904db2052270eb0d5ded97a Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:44:01 +0000 Subject: [PATCH 055/188] rotate clockwise, with the sampling modes of transform --- src/image.ts | 85 +++++---------------------- src/util/rotate.ts | 57 ------------------ tests/gainmap.js | 6 +- tests/regressions.js | 20 +++---- tests/rotate.js | 42 +++++++------ tests/targets/rotate-45-noresize.png | Bin 2239 -> 2031 bytes tests/targets/rotate-45.png | Bin 3637 -> 3150 bytes tests/transform.js | 8 +-- 8 files changed, 53 insertions(+), 165 deletions(-) delete mode 100644 src/util/rotate.ts diff --git a/src/image.ts b/src/image.ts index 86ad4532..dea3e2d5 100644 --- a/src/image.ts +++ b/src/image.ts @@ -30,7 +30,6 @@ import { TextLayout, TextLayoutOptions } from './text-layout.ts'; import { Metadata, resolveMetadata, pngText, exifData } from './metadata.ts'; import { encode as utf8 } from './util/utf8.ts'; import { resample, BOX, TRIANGLE, CUBIC, type Region } from './util/resample.ts'; -import { rotateBilinear } from './util/rotate.ts'; import { gaussianBlur, boxBlurRGBA, premultiply, unpremultiply } from './util/blur.ts'; import { Colors, wrapHue, hue2rgb } from './colors.ts'; import { isOptions, checkOptions, finite, color as checkColor, quality as checkQuality, effort as checkEffort, compressionLevel } from './util/options.ts'; @@ -548,12 +547,11 @@ export interface RotateOptions { */ expand?: boolean; /** - * Whether to sample with 8-bit weights on 8-bit premultiplied pixels (like {@link ResizeOptions.fast}), about twice - * as fast: opaque pixels stay within a step of the precise result, what shows of translucent ones (also the - * faded edges) within about 2.5 steps (pixels that are almost transparent keep less of their color). Only affects - * angles that aren't multiples of 90°. Default: `true` + * How the pixels between the source's are sampled for angles that aren't multiples of 90° (those move whole + * pixels): `'nearest-neighbor'` (hard pixels, for pixel art), `'bilinear'` (smooth) or `'bicubic'` (sharper, can + * overshoot a little at hard edges), like {@link TransformOptions.mode}. Default: `'bilinear'` */ - fast?: boolean; + mode?: TransformMode; } /** Options of {@link Image.trim} */ @@ -2163,15 +2161,19 @@ export class Image { } /** - * Rotates the image counterclockwise by the given amount of degrees - * @param angle The angle to rotate the image for (in degrees) + * Rotates the image clockwise by the given angle in degrees (like CSS' `rotate()`; negative angles turn it + * counterclockwise). Multiples of 90° move whole pixels; other angles resample the image (see + * {@link RotateOptions.mode}) and, by default, grow it so the corners fit + * @param angle The angle in degrees, clockwise * @param options See {@link RotateOptions} */ rotate(angle: number, options?: RotateOptions): this; rotate(angle: number, options: RotateOptions = {}): this { checkOptions(options, 'rotate(angle, { expand: false })'); - const { expand = true, fast = true } = options; - angle = ((finite(angle, 'angle') % 360) + 360) % 360; + const { expand = true, mode = 'bilinear' } = options; + if (!TRANSFORM_MODES.includes(mode)) throw new TypeError(`rotate mode must be one of ${TRANSFORM_MODES.map(name => `'${name}'`).join(', ')}, not ${String(mode)}`); + // (counterclockwise from here on: the image's y axis points down, so this turns it clockwise on screen) + angle = ((-finite(angle, 'angle') % 360) + 360) % 360; if (angle === 0) return this; const {width, height} = this; @@ -2209,65 +2211,10 @@ export class Image { const outHeight = expand ? Math.round(Math.abs(width * sin) + Math.abs(height * cos)) : height; const out = new Image(outWidth, outHeight); rotateGainMap(outWidth, outHeight); - if (fast && rotateBilinear(this.bitmap, width, height, out.bitmap, outWidth, outHeight, sin, cos)) return this.__apply__(out); - const s8 = this.bitmap, d8 = out.bitmap; - - // bilinear sampling of the counterclockwise rotated source, with premultiplied alpha - for (let oy = 0; oy < outHeight; oy++) { - for (let ox = 0; ox < outWidth; ox++) { - const dx = ox + 0.5 - outWidth / 2, dy = oy + 0.5 - outHeight / 2; - const sx = dx * cos - dy * sin + width / 2 - 0.5; - const sy = dx * sin + dy * cos + height / 2 - 0.5; - const x0 = Math.floor(sx), y0 = Math.floor(sy); - if (x0 < -1 || y0 < -1 || x0 >= width || y0 >= height) continue; - - const fx = sx - x0, fy = sy - y0; - const w00 = (1 - fx) * (1 - fy), w10 = fx * (1 - fy), w01 = (1 - fx) * fy, w11 = fx * fy; - let r = 0, g = 0, b = 0, a = 0; - - // taps outside the source are skipped (zero weight taps add nothing either way) - const left = x0 >= 0, right = x0 + 1 < width, top = y0 >= 0, bottom = y0 + 1 < height; - let i = 4 * (y0 * width + x0); - if (top && left) { - const wa = w00 * s8[i + 3]; - r += wa * s8[i]; - g += wa * s8[i + 1]; - b += wa * s8[i + 2]; - a += wa; - } - if (top && right) { - const wa = w10 * s8[i + 7]; - r += wa * s8[i + 4]; - g += wa * s8[i + 5]; - b += wa * s8[i + 6]; - a += wa; - } - i += 4 * width; - if (bottom && left) { - const wa = w01 * s8[i + 3]; - r += wa * s8[i]; - g += wa * s8[i + 1]; - b += wa * s8[i + 2]; - a += wa; - } - if (bottom && right) { - const wa = w11 * s8[i + 7]; - r += wa * s8[i + 4]; - g += wa * s8[i + 5]; - b += wa * s8[i + 6]; - a += wa; - } - - if (a <= 0) continue; - // the clamped bitmap rounds on assignment - const o = 4 * (oy * outWidth + ox); - d8[o] = r / a; - d8[o + 1] = g / a; - d8[o + 2] = b / a; - d8[o + 3] = a; - } - } - + // the source position of each output position (pixel centers), around the middles of both images + const inverse: [number, number, number, number, number, number] = [cos, sin, -sin, cos, + width / 2 - cos * outWidth / 2 + sin * outHeight / 2, height / 2 - sin * outWidth / 2 - cos * outHeight / 2]; + sampleAffine(this.bitmap, width, height, out.bitmap, outWidth, outHeight, inverse, 0, 0, mode); return this.__apply__(out); } diff --git a/src/util/rotate.ts b/src/util/rotate.ts deleted file mode 100644 index 57dc784f..00000000 --- a/src/util/rotate.ts +++ /dev/null @@ -1,57 +0,0 @@ -// bilinear rotation on the pixels as 32-bit words: 8-bit premultiplied pixels and 8-bit weights, so red and blue, -// green and alpha share a multiplication (like the fast resampling) - -import { littleEndian, isOpaque, premultiply8, unpremultiply8 } from './alpha.ts'; - -// the range of steps x (within from..to) where start + x * step lies within 0..limit, widened by a step on either side -function span(start: number, step: number, limit: number, from: number, to: number): [number, number] { - if (step === 0) return start >= 0 && start < limit ? [from, to] : [from, from]; - const a = -start / step, b = (limit - start) / step; - const lo = Math.floor(Math.min(a, b)) - 1, hi = Math.ceil(Math.max(a, b)) + 1; - return [Math.max(from, lo), Math.max(Math.max(from, lo), Math.min(to, hi))]; -} - -/** - * Samples the source rotated counterclockwise (by the angle of `sin` and `cos`) around its center into `out`, centered - * as well. Pixels outside the source stay transparent, edges fade like the taps outside the source were transparent. - * Returns false (without writing anything) on big endian machines - */ -export function rotateBilinear(src: Uint8ClampedArray, width: number, height: number, out: Uint8ClampedArray, outWidth: number, outHeight: number, sin: number, cos: number): boolean { - if (!littleEndian) return false; - - // premultiplied, with a transparent border of one pixel: taps outside the source need no checks - let bitmap = src; - if (!isOpaque(src)) premultiply8(src, bitmap = new Uint8ClampedArray(src.length)); - const pixels = new Int32Array(bitmap.buffer, bitmap.byteOffset, width * height); - const stride = width + 2; - const padded = new Int32Array(stride * (height + 2)); - for (let y = 0; y < height; y++) padded.set(pixels.subarray(y * width, (y + 1) * width), (y + 1) * stride + 1); - - const result = new Int32Array(out.buffer, out.byteOffset, outWidth * outHeight); - // source position (in padded coordinates) of the center of the first pixel in a row, then one step per pixel - const x0 = 0.5 - outWidth / 2, cx = width / 2 + 0.5, cy = height / 2 + 0.5; - for (let oy = 0; oy < outHeight; oy++) { - const dy = oy + 0.5 - outHeight / 2; - const sx0 = x0 * cos - dy * sin + cx, sy0 = x0 * sin + dy * cos + cy; - // the pixels of the row that hit the source (give or take one, the check below is exact) - const [xFrom, xTo] = span(sx0, cos, width + 1, 0, outWidth); - const [from, to] = span(sy0, sin, height + 1, xFrom, xTo); - let o = oy * outWidth + from, sx = sx0 + from * cos, sy = sy0 + from * sin; - for (let ox = from; ox < to; ox++, o++, sx += cos, sy += sin) { - if (!(sx >= 0 && sy >= 0 && sx < width + 1 && sy < height + 1)) continue; - const ix = sx | 0, iy = sy | 0; - // 8-bit fractions, and weights summing to 256 - const fx = (sx - ix) * 256 + 0.5 | 0, fy = (sy - iy) * 256 + 0.5 | 0; - const w11 = fx * fy + 128 >> 8, w10 = fx - w11, w01 = fy - w11, w00 = 256 - fx - fy + w11; - const i = iy * stride + ix; - const p00 = padded[i], p10 = padded[i + 1], p01 = padded[i + stride], p11 = padded[i + stride + 1]; - const rb = Math.imul(p00 & 0xff00ff, w00) + Math.imul(p10 & 0xff00ff, w10) + Math.imul(p01 & 0xff00ff, w01) + Math.imul(p11 & 0xff00ff, w11) + 0x800080; - const ga = Math.imul(p00 >>> 8 & 0xff00ff, w00) + Math.imul(p10 >>> 8 & 0xff00ff, w10) + Math.imul(p01 >>> 8 & 0xff00ff, w01) + Math.imul(p11 >>> 8 & 0xff00ff, w11) + 0x800080; - result[o] = (rb >>> 8 & 0xff00ff) | (ga & 0xff00ff00); - } - } - - // the edges are translucent even for opaque sources - unpremultiply8(out); - return true; -} diff --git a/tests/gainmap.js b/tests/gainmap.js index 014ca1ec..022e3733 100644 --- a/tests/gainmap.js +++ b/tests/gainmap.js @@ -140,9 +140,9 @@ const meanValue = (image, left, top, right, bottom) => { const oriented = Buffer.concat([files['multi-channel'].subarray(0, app0End), segment, files['multi-channel'].subarray(app0End)]); const image = await Image.decode(oriented); if (image.width !== 384 || !image.gainMap || image.gainMap.width !== 384) panic(`orientation: ${image.width}, map ${image.gainMap?.width}`); - // (orientation 6: turned clockwise, rotate(270)) - if (!same(image, multi.clone().rotate(270))) panic('orientation: test file wrong'); - if (!same(image.gainMap.image, multi.gainMap.image.clone().rotate(270))) panic('orientation: the map wasn\'t turned like the image'); + // (orientation 6: turned clockwise, rotate(90)) + if (!same(image, multi.clone().rotate(90))) panic('orientation: test file wrong'); + if (!same(image.gainMap.image, multi.gainMap.image.clone().rotate(90))) panic('orientation: the map wasn\'t turned like the image'); } // drawing over it: no boost where something without one went, the rest as it was diff --git a/tests/regressions.js b/tests/regressions.js index 4646aac1..920927ef 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -17,17 +17,17 @@ const assert = (condition, message) => { const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); (async () => { - // rotating non-square images by right angles (#29) + // rotating non-square images by right angles (#29): rotate() turns clockwise { const image = new Image(3, 2).fill((x, y) => Colors.fromRGBA(x + 1, y + 1, 0, 255)); - const ccw = image.clone().rotate(90); - assert(ccw.width === 2 && ccw.height === 3, 'rotate(90): dimensions not swapped'); - assert(rgba(ccw, 0, 2).slice(0, 2).join() === '1,1', 'rotate(90): top left should end up bottom left'); - assert(rgba(ccw, 1, 0).slice(0, 2).join() === '3,2', 'rotate(90): bottom right should end up top right'); - - const cw = image.clone().rotate(270); - assert(rgba(cw, 1, 0).slice(0, 2).join() === '1,1', 'rotate(270): top left should end up top right'); - for (const [x, y, color] of cw.pixels()) assert((color & 0xff) === 255, `rotate(270): hole at ${x},${y}`); + const ccw = image.clone().rotate(270); + assert(ccw.width === 2 && ccw.height === 3, 'rotate(270): dimensions not swapped'); + assert(rgba(ccw, 0, 2).slice(0, 2).join() === '1,1', 'rotate(270): top left should end up bottom left'); + assert(rgba(ccw, 1, 0).slice(0, 2).join() === '3,2', 'rotate(270): bottom right should end up top right'); + + const cw = image.clone().rotate(90); + assert(rgba(cw, 1, 0).slice(0, 2).join() === '1,1', 'rotate(90): top left should end up top right'); + for (const [x, y, color] of cw.pixels()) assert((color & 0xff) === 255, `rotate(90): hole at ${x},${y}`); } // free rotation keeps the edges @@ -313,7 +313,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const read = n => fs.readFileSync(`./tests/jpegs/exif-orientation-${n}.jpg`); const raw = await Image.decode(read(3), {applyOrientation: false}); assert(raw.width === 48 && raw.height === 32, 'decode: applyOrientation: false should keep the stored pixels'); - for (const [n, angle] of [[3, 180], [6, 270], [8, 90]]) { + for (const [n, angle] of [[3, 180], [6, 90], [8, 270]]) { const oriented = await Image.decode(read(n)); const expected = (await Image.decode(read(n), {applyOrientation: false})).rotate(angle); assert(oriented.width === expected.width && Buffer.from(oriented.bitmap).equals(Buffer.from(expected.bitmap)), `decode: orientation ${n} not applied`); diff --git a/tests/rotate.js b/tests/rotate.js index 6f0c2c57..55d89359 100644 --- a/tests/rotate.js +++ b/tests/rotate.js @@ -10,7 +10,7 @@ const panic = msg => { { const binary = await fs.readFile('./tests/targets/image.png'); const image = await Image.decode(binary); - image.rotate(45); + image.rotate(-45); const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); @@ -23,7 +23,7 @@ const panic = msg => { { const binary = await fs.readFile('./tests/targets/image.png'); const image = await Image.decode(binary); - image.rotate(45, {expand: false}); + image.rotate(-45, {expand: false}); const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); @@ -37,7 +37,7 @@ const panic = msg => { { const binary = await fs.readFile('./tests/targets/image.png'); const image = await Image.decode(binary); - image.rotate(90); + image.rotate(-90); const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); @@ -63,7 +63,7 @@ const panic = msg => { { const binary = await fs.readFile('./tests/targets/image.png'); const image = await Image.decode(binary); - image.rotate(270); + image.rotate(-270); const encoded = await image.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); @@ -80,24 +80,22 @@ const panic = msg => { panic('rotate 360 failed'); } - // the fast sampling (the default) stays within a step of the precise one for opaque pixels, transparent ones - // within 2.5 steps where it shows (premultiplied, alpha within 2) + // rotate() turns clockwise; the modes sample like transform()'s, nearest-neighbor only keeps source colors { - const photo = (await Image.decode(await fs.readFile('./bench/inputs/photo.jpg'))).crop(700, 300, 400, 300); - const transparent = photo.clone(); - for (let i = 0; i < transparent.bitmap.length; i += 4) transparent.bitmap[i + 3] = (i >> 2) % transparent.width * 255 / (transparent.width - 1); - for (const source of [photo, transparent]) - for (const [angle, resize] of [[30, true], [-17.5, true], [123, false], [0.3, true]]) { - const a = source.clone().rotate(angle, {expand: resize, fast: false}).bitmap, b = source.clone().rotate(angle, {expand: resize}).bitmap; - let opaque = 0, shown = 0; - for (let i = 0; i < a.length; i++) { - if (a[i] === b[i]) continue; - const al = a[i | 3], bl = b[i | 3]; - if ((i & 3) === 3) shown = Math.max(shown, Math.abs(a[i] - b[i])); - else if (al === 255 && bl === 255) opaque = Math.max(opaque, Math.abs(a[i] - b[i])); - else shown = Math.max(shown, Math.abs(a[i] * al - b[i] * bl) / 255); - } - if (opaque > 1 || shown > 2.5) panic(`fast rotate ${angle}: ${opaque} (opaque), ${shown} (shown)`); - } + const source = new Image(8, 6).fill((x, y) => Colors.fromRGBA(x * 30, y * 40, 100, 255)); + const cw = source.clone().rotate(90); + if (cw.getPixelAt(cw.width - 1, 0) !== source.getPixelAt(0, 0)) panic('rotate(90) should turn clockwise: the top left corner goes to the top right'); + const colors = new Set(source.bitmap.length ? [...source.pixels()].map(([, , c]) => c) : []); + const nearest = source.clone().rotate(30, {mode: 'nearest-neighbor'}); + for (const [x, y, c] of nearest.pixels()) if ((c & 0xff) && !colors.has(c)) panic(`rotate nearest-neighbor: new color at ${x},${y}`); + const bicubic = source.clone().rotate(30, {mode: 'bicubic'}), bilinear = source.clone().rotate(30); + if (bicubic.width !== bilinear.width || Buffer.from(bicubic.bitmap).equals(Buffer.from(bilinear.bitmap))) panic('rotate bicubic should differ from bilinear'); + const angle = 30 * Math.PI / 180; + const viaTransform = source.clone().transform([Math.cos(angle), Math.sin(angle), -Math.sin(angle), Math.cos(angle), 0, 0], {expand: false, mode: 'bicubic'}); + const viaRotate = source.clone().rotate(30, {expand: false, mode: 'bicubic'}); + if (!Buffer.from(viaTransform.bitmap).equals(Buffer.from(viaRotate.bitmap))) panic('rotate should sample like transform'); + let error = null; + try { source.clone().rotate(30, {mode: 'area'}); } catch (e) { error = e; } + if (!(error instanceof TypeError)) panic(`rotate mode 'area' should throw a TypeError, got ${error}`); } })(); diff --git a/tests/targets/rotate-45-noresize.png b/tests/targets/rotate-45-noresize.png index 8090ddd0dbc4ec2ea302e27b0a8ea6e8593a5577..55bf67df38219fc6b826172c1a5534897df3c016 100644 GIT binary patch literal 2031 zcmYLK2~bma9{$I?4G(C@3vyJV$qT4xQHd3Tg-Q+(nkq+;T}286k!)P#G$2bbdGq8@ zSc(p#TciS8W^h_pVYy_X5CXcw20W&Mawnoti$^qyC;|2blG&MeeE*r>@qOR#_vW1q z4-KZ;xYz&ysG_d|BZxOp^rh?Hh-LpeLq zuj5nDonQEHP!7j;eoE2%$WOG(GsJGLxR_3L zg}F9dyd-}s#IR+k|I-~*|F3%b|KS;NLVAhVP3q=B7cc~f`qsX=Ud5Zl>0wP(`ICzB zS05yEb0Wh_Nm<`7uMC=YtBQS}&kjEoov2|*$`TrN)Lnku#)h28^izW`4%YFS@?#qP zBw3^Vl1tKmb4eZYmeO^quRC~$Q0so527*((R0(VS{>(Gb{eMPeG%eewp*+M%((~`- z=G^bnA_lLPR7ZobC%UPVOWpl>sH4%C-bU7s<;+W?Va{2vW(M%QP*{2~ACzeyz6G(P z8~hJDt=W%9|GFmIV_R<1Ht2h(u9mEi?a<$EK*aVxT%vRZ4|E4>gqY}NT`s6Yi3`vO_(9=eL>y!|m=tF9)I-L-LKuoZ;|tenf2AqsIQ!6L z%Is_e_E8GW9Lq|tGVy|+zt7$PyiDQnVZb=@-rN&zc_SJeu_6Uf!3am`;St)8avO=H)4 zI15fwv6YoM$i(|qvss2>#H%W1T%Lh6Jpxib%&}IKlve9hByO}LK5$mwSz3&CQ}|a+ z*-2kQ=eb~I$>OHaGp(e1roA$bFa=M{MIUCtFawyjN?kWVKW7Q9V51gZL&5wBU|KJ0 z#kE3IZRVA<8xOSP#lsB=RehM!_asXIzr2Gl4l1DG%;g=$OS>ZTu}eX5ll`AF`undh z94xRQIbytQ7X9dwiuBQ4h1@g5`ST}{qnfaAPX!rP4+hka0cmuF=?m#Rju%^4`lE+N z%_Ls9sD~^h{tkcbW0u!0tY3?zMz4;7AO67uxNm~4mT8rjUDOjqnPzK3K*(8CYWCRM z5!YI(JHUTQHcz2}{=lL((N+3M*NyFvQU4m=23-R%>9Od*I@o2AA#RLGUbVWU`klq< z{m?fS_Y&A=TFh3vkOdDcZCc{f_Z`U1il?1!e?;|R#&j;n zFI`xlTJTyiQ5yDDOu_P1YUstP^`Akn-%x!cZosu!DO0NCw8wYfJ#IXmk8Wwp_W7Nn zmc?_IQY#BplrE*)YUE)^)110IMpfEkNI#xYoKxckztJnBX(|T{89QOZkL*?B~ zic#9~V2;jb4GMJxoGV;DvZ!Z}5qtve?NQc7%@czW%n+%pB&H$>2=A>UQmsbvS%1Kg zjlrB|GGlb0=INwi(K!0;j#%2gX>_Uc>rFA3p51rCWL0$$tZecm)x`cHYK~ zAg`KB1k--gxf8)^xJb_v*()M|u-;ifUvlp24zuIKpV1w3$}%rwD1g;YMD(k&!pU`n zcs@~NpS$iPQCqTiUYY5nm;u*0_)o(}a=E)-co}0sD-K`0YgAfYH|XVLtquE7!SX^^ o4qZ_Pz9m5#z4G}t^NQ(Za+3cZjcgN5N&LZqXm4oXb)gvlAITza!~g&Q literal 2239 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 K-KoNn8vlRNO4J_! delta 3581 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 diff --git a/tests/transform.js b/tests/transform.js index 2678dfdc..39dad584 100644 --- a/tests/transform.js +++ b/tests/transform.js @@ -28,16 +28,16 @@ const photo = (width, height) => new Image(width, height).fill((x, y) => Colors. // flips and quarter turns are exact (the library's rotate() turns counterclockwise) check(same(source.clone().transform([-1, 0, 0, 1, 0, 0]), source.clone().flip('horizontal')), 'transform: flipped horizontally'); check(same(source.clone().transform([1, 0, 0, -1, 0, 0]), source.clone().flip('vertical')), 'transform: flipped vertically'); - check(same(source.clone().transform([0, -1, 1, 0, 0, 0]), source.clone().rotate(90)), 'transform: turned 90 degrees counterclockwise'); + check(same(source.clone().transform([0, -1, 1, 0, 0, 0]), source.clone().rotate(-90)), 'transform: turned 90 degrees counterclockwise'); check(same(source.clone().transform([-1, 0, 0, -1, 0, 0]), source.clone().rotate(180)), 'transform: turned 180 degrees'); - check(same(source.clone().transform([0, 1, -1, 0, 0, 0]), source.clone().rotate(270)), 'transform: turned 270 degrees'); + check(same(source.clone().transform([0, 1, -1, 0, 0, 0]), source.clone().rotate(-270)), 'transform: turned 270 degrees'); const turned = source.clone().transform([0, -1, 1, 0, 0, 0]); check(turned.width === 9 && turned.height === 13, `transform: a quarter turn swaps the size: ${turned.width}x${turned.height}`); - // any angle: close to rotate() (counterclockwise 30 degrees), on a canvas that keeps its size + // any angle: rotate() (counterclockwise 30 degrees: -30), on a canvas that keeps its size const angle = 30 * Math.PI / 180, big = photo(40, 30); const ours = big.clone().transform([Math.cos(angle), -Math.sin(angle), Math.sin(angle), Math.cos(angle), 0, 0], {expand: false}); - const theirs = big.clone().rotate(30, {expand: false}); + const theirs = big.clone().rotate(-30, {expand: false}); let total = 0; for (let i = 0; i < ours.bitmap.length; i++) total += Math.abs(ours.bitmap[i] - theirs.bitmap[i]); check(ours.width === 40 && ours.height === 30 && total / ours.bitmap.length < 3, `transform: a rotation by any angle is rotate()'s (mean difference ${total / ours.bitmap.length})`); From d5e4ce9ea1ca497ed22e90ddec7813809c5d9c94 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:44:41 +0000 Subject: [PATCH 056/188] let renderSVG take one font without an array --- src/image.ts | 7 ++++--- tests/api-shapes.js | 8 ++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/image.ts b/src/image.ts index dea3e2d5..9b78a267 100644 --- a/src/image.ts +++ b/src/image.ts @@ -360,9 +360,10 @@ export interface SVGOptions { * installed fonts). Generic families (`serif`, `sans-serif`, `monospace`, ...) are the first font that fits them, * other families and text without one get the first font's family; characters a face doesn't have come from the * other fonts (TrueType, OpenType or WOFF). Fonts the SVG embeds with `@font-face` (in data: URIs) are used too, - * before these for their family names. Without any fonts text isn't rendered. Default: `[]` + * before these for their family names. Without any fonts text isn't rendered. One font can be given without an + * array. Default: `[]` */ - fonts?: BinaryData[]; + fonts?: BinaryData | BinaryData[]; /** * Images for the `` elements (and `feImage`) by their `href`, the same string the SVG uses: with * `images: { 'logo.png': bytes }`, `` draws the image. Values are encoded images (PNG, @@ -2724,7 +2725,7 @@ export class Image { } } - const {width, height, pixels} = svgRenderer.render(source, mode, size, images, fonts.map(font => view(font))); + const {width, height, pixels} = svgRenderer.render(source, mode, size, images, (Array.isArray(fonts) ? fonts : [fonts]).map(font => view(font))); const image = new Image(width, height); image.bitmap.set(pixels); diff --git a/tests/api-shapes.js b/tests/api-shapes.js index 7fd750e5..005ff31e 100644 --- a/tests/api-shapes.js +++ b/tests/api-shapes.js @@ -148,6 +148,14 @@ const throws = (call, type, message) => { assert(Colors.gradient([0xff0000ff])(0.3) === 0xff0000ff, 'gradient of one color'); } + // renderSVG takes one font without an array + { + const font = require('fs').readFileSync('./tests/fonts/opensans bold.ttf'); + const svg = 'Hi'; + const one = await Image.renderSVG(svg, {fonts: font}), list = await Image.renderSVG(svg, {fonts: [font]}); + assert(Buffer.from(one.bitmap).equals(Buffer.from(list.bitmap)) && one.bitmap.some(v => v), 'renderSVG: one font like a list of one'); + } + // renamed parameters and the American spelling { const gray = new Image(1, 1).fill(0xff0000ff).grayscale(); From d418216c00b8324b3068801ebda98ffa12fff992 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:48:57 +0000 Subject: [PATCH 057/188] add Image.encodeGIF and replace the GIF quality with colors and dithering --- src/animation-encoder.ts | 12 ++++---- src/animation.ts | 35 ++++++++++------------ src/codecs/gif.ts | 26 ++++++++-------- src/codecs/quantize.ts | 64 +++++++++++++++++++++++++++++----------- src/image.ts | 57 +++++++++++++++++++++++++---------- src/index.ts | 4 +-- tests/api-shapes.js | 22 ++++++++++++++ tests/gif.js | 2 +- tests/option-checks.js | 8 +++-- tests/options-only.js | 2 +- 10 files changed, 157 insertions(+), 75 deletions(-) diff --git a/src/animation-encoder.ts b/src/animation-encoder.ts index bb1d0e0e..78948f08 100644 --- a/src/animation-encoder.ts +++ b/src/animation-encoder.ts @@ -1,4 +1,5 @@ import * as gifCodec from './codecs/gif.ts'; +import { paletteOptions } from './codecs/quantize.ts'; import * as png from './codecs/png.ts'; import * as webp from './codecs/webp.ts'; import { Image } from './image.ts'; @@ -7,7 +8,7 @@ import { Optimizer, Compositor, InputFrame, OptimizeOptions, OptimizedFrame, cov import { compressAsync, decompressAsync } from './codecs/zlib.ts'; import { resolveMetadata, pngText, exifData, gifComments } from './metadata.ts'; import { checkOptions, finite, compressionLevel, loopCount, toPlays, fitLoops } from './util/options.ts'; -import type { AnimationOptions, WebPAnimationEncodeOptions, GIFEncodeOptions, APNGEncodeOptions } from './animation.ts'; +import type { AnimationOptions, WebPAnimationEncodeOptions, GIFAnimationEncodeOptions, APNGEncodeOptions } from './animation.ts'; // a format's part: takes the frames in order once they can't change anymore, then makes the file interface Format { @@ -121,8 +122,9 @@ export class AnimationEncoder { } /** @internal */ - static __gif__(width: number, height: number, { loopCount: count = Infinity, metadata = {}, optimize = true, quality: qualityOption = 95 }: GIFEncodeOptions & AnimationOptions): AnimationEncoder { - const quality = finite(qualityOption, 'quality'); + static __gif__(width: number, height: number, { loopCount: count = Infinity, metadata = {}, optimize = true, colors = 256, dither = 'floyd-steinberg', ditherStrength = 0.85 }: GIFAnimationEncodeOptions & AnimationOptions): AnimationEncoder { + const checked = paletteOptions(colors, dither, ditherStrength); + const palette = { colors: checked.colors as number, dither: checked.dither, strength: checked.strength }; const loops = fitLoops(loopCount(count), 'GIF'); canvas(width, height); const comments = gifComments(resolveMetadata(metadata)); @@ -139,11 +141,11 @@ export class AnimationEncoder { delay: Math.round(f.duration / 10) }; restores ||= gif.dispose === 3; if (!first) first = gif; - else rest.push(gifCodec.encodeFrame(gif, quality, gif.dispose === 3)); + else rest.push(gifCodec.encodeFrame(gif, palette, gif.dispose === 3)); }, file() { const f = first as gifCodec.GIFFrame; - return concat([gifCodec.header(width, height, loops, comments), gifCodec.encodeFrame(f, quality, f.dispose === 3 || restores), ...rest, new Uint8Array([0x3b])]); + return concat([gifCodec.header(width, height, loops, comments), gifCodec.encodeFrame(f, palette, f.dispose === 3 || restores), ...rest, new Uint8Array([0x3b])]); } }, optimize); } diff --git a/src/animation.ts b/src/animation.ts index 9a9cb835..14bb423b 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -6,11 +6,12 @@ import * as jxl from './codecs/jxl.ts'; import { availableThreads } from './util/threads.ts'; import { BinaryData, view } from './util/mem.ts'; import { GainMap } from './gainmap.ts'; -import { Image, DecodeOptions, ContainOptions, ResizeOptions, ResizeToOptions, resizeArguments, insideSize } from './image.ts'; +import { Image, DecodeOptions, EncodeOptions, GIFEncodeOptions, ContainOptions, ResizeOptions, ResizeToOptions, resizeArguments, insideSize } from './image.ts'; import { Frame } from './frame.ts'; import { ImageType } from './image-type.ts'; import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts'; -import { Metadata, resolveMetadata, pngText, exifData, gifComments } from './metadata.ts'; +import { paletteOptions } from './codecs/quantize.ts'; +import { resolveMetadata, pngText, exifData, gifComments } from './metadata.ts'; import { checkOptions, finite, compressionLevel, loopCount, toPlays, fromPlays, fitLoops, color as checkColor } from './util/options.ts'; import { MAX_PIXELS, decoding } from './util/limits.ts'; import { AnimationEncoder } from './animation-encoder.ts'; @@ -47,9 +48,7 @@ export interface AnimationCanvasOptions extends AnimationOptions { } /** The options all animation encoders have */ -export interface AnimationEncodeOptions { - /** Metadata to include, see {@link Metadata} */ - metadata?: Metadata; +export interface AnimationEncodeOptions extends EncodeOptions { /** * Whether to store each frame as the area that changed since the frame before and merge identical frames, which * usually makes files a lot smaller. The file shows the same animation either way, but only decodes to the same @@ -74,11 +73,8 @@ export interface WebPAnimationEncodeOptions extends AnimationEncodeOptions { alphaQuality?: number; } -/** Options of {@link Animation.encodeGIF} */ -export interface GIFEncodeOptions extends AnimationEncodeOptions { - /** 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above). Default: `95` */ - quality?: number; -} +/** Options of {@link Animation.encodeGIF} and {@link Animation.gifEncoder}: the palette of {@link GIFEncodeOptions} and the animation options */ +export interface GIFAnimationEncodeOptions extends GIFEncodeOptions, AnimationEncodeOptions {} /** Options of {@link Animation.encodeAPNG} */ export interface APNGEncodeOptions extends AnimationEncodeOptions { @@ -350,12 +346,13 @@ export class Animation { * before and merges identical frames (unless the `optimize` option is false), so the file decodes to what the * animation shows, but not necessarily to the same frames. Semi-transparent pixels become opaque or transparent * after the frames are composited - * @param options See {@link GIFEncodeOptions} + * @param options See {@link GIFAnimationEncodeOptions} */ - encodeGIF(options?: GIFEncodeOptions): Promise>; - async encodeGIF(options: GIFEncodeOptions = {}): Promise> { - checkOptions(options, 'encodeGIF({ quality: 80 })'); - const { quality = 95, metadata = {}, optimize = true } = options; + encodeGIF(options?: GIFAnimationEncodeOptions): Promise>; + async encodeGIF(options: GIFAnimationEncodeOptions = {}): Promise> { + checkOptions(options, 'encodeGIF({ colors: 64 })'); + const { colors = 256, dither = 'floyd-steinberg', ditherStrength = 0.85, metadata = {}, optimize = true } = options; + const palette = paletteOptions(colors, dither, ditherStrength); const loops = fitLoops(this.loopCount, 'GIF'); this.__validate__(); const frames = optimize @@ -372,7 +369,7 @@ export class Animation { // gifs store centiseconds; rounding keeps e.g. 16 ms (60 fps) from becoming 10 ms const gifFrames = frames.map(({ duration, ...f }) => ({ ...f, delay: Math.round(duration / 10) })); - return gifCodec.encode({ width: this.width, height: this.height, loopCount: loops, frames: gifFrames }, finite(quality, 'quality'), gifComments(resolveMetadata(metadata))); + return gifCodec.encode({ width: this.width, height: this.height, loopCount: loops, frames: gifFrames }, { colors: palette.colors as number, dither: palette.dither, strength: palette.strength }, gifComments(resolveMetadata(metadata))); } /** @@ -489,10 +486,10 @@ export class Animation { * without keeping the frames in memory: `await encoder.add(frame)` for each frame, then `await encoder.finish()` * @param width The canvas width * @param height The canvas height - * @param options See {@link GIFEncodeOptions}, and {@link AnimationOptions.loopCount} + * @param options See {@link GIFAnimationEncodeOptions}, and {@link AnimationOptions.loopCount} */ - static gifEncoder(width: number, height: number, options: GIFEncodeOptions & AnimationOptions = {}): AnimationEncoder { - checkOptions(options, 'Animation.gifEncoder(width, height, { quality: 80 })'); + static gifEncoder(width: number, height: number, options: GIFAnimationEncodeOptions & AnimationOptions = {}): AnimationEncoder { + checkOptions(options, 'Animation.gifEncoder(width, height, { colors: 64 })'); return AnimationEncoder.__gif__(width, height, options); } diff --git a/src/codecs/gif.ts b/src/codecs/gif.ts index 58281777..9c54bc77 100644 --- a/src/codecs/gif.ts +++ b/src/codecs/gif.ts @@ -1,5 +1,5 @@ import { ByteWriter } from '../util/bytes.ts'; -import { quantize } from './quantize.ts'; +import { quantize, type DitherMethod } from './quantize.ts'; import { MAX_PIXELS, PixelBudget, checkPixels } from '../util/limits.ts'; // ImageScript disposal modes (any, keep, previous, background) <-> GIF disposal methods (unspecified, none, background, previous) @@ -60,22 +60,24 @@ interface GraphicControl { transparentIndex: number; } +/** The palette of every frame: its size (at most 256 colors, the transparent one included) and the dithering */ +export interface GIFPalette { + colors: number; + dither: DitherMethod; + strength: number; +} + /** - * Encodes frames into a GIF - * @param {{width: number, height: number, loopCount?: number, frames: GIFFrame[]}} gif loopCount Infinity loops forever, 0 plays once, n repeats n times - * @param {number} [quality=95] 1-100, scales the palette size (1 = 8 colors, 50 = 55, 90 and above = 256) - * @return {Uint8Array} - */ -/** - * @param comments written as comment extensions + * Encodes frames into a GIF. `loopCount`: Infinity loops forever, 0 plays once (no loop extension), n repeats n times; + * `comments` are written as comment extensions */ -export function encode({ width, height, loopCount = Infinity, frames }: Omit & { loopCount?: number }, quality = 95, comments: Uint8Array[] = []): Uint8Array { +export function encode({ width, height, loopCount = Infinity, frames }: Omit & { loopCount?: number }, palette: GIFPalette, comments: Uint8Array[] = []): Uint8Array { // frames that restore the background have a transparent index: decoders like Pillow and ffmpeg restore to it // (browsers to transparent), otherwise to the background color. So does the first frame then: Pillow decides by it // whether the animation has transparency at all const restores = frames.some(frame => frame.dispose === 3); const parts = [header(width, height, loopCount, comments)]; - for (const [i, frame] of frames.entries()) parts.push(encodeFrame(frame, quality, frame.dispose === 3 || (i === 0 && restores))); + for (const [i, frame] of frames.entries()) parts.push(encodeFrame(frame, palette, frame.dispose === 3 || (i === 0 && restores))); parts.push(new Uint8Array([0x3b])); const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let offset = 0; @@ -111,9 +113,9 @@ export function header(width: number, height: number, loopCount: number, comment } /** A frame (its graphic control extension, image descriptor, color table and pixels), with a transparent index if asked */ -export function encodeFrame(frame: GIFFrame, quality: number, transparent: boolean): Uint8Array { +export function encodeFrame(frame: GIFFrame, colors: GIFPalette, transparent: boolean): Uint8Array { const out = new GIFWriter(); - const { palette, indices, transparentIndex } = quantize(frame.pixels, frame.width, { quality, transparent }); + const { palette, indices, transparentIndex } = quantize(frame.pixels, frame.width, { maxColors: colors.colors, dither: colors.dither, ditherStrength: colors.strength, transparent }); const entries = palette.length / 3; const bits = Math.max(1, Math.ceil(Math.log2(entries))); diff --git a/src/codecs/quantize.ts b/src/codecs/quantize.ts index 95b2cb0a..da7c980e 100644 --- a/src/codecs/quantize.ts +++ b/src/codecs/quantize.ts @@ -2,21 +2,39 @@ // perceptual channel weights for squared color distances const WR = 3, WG = 4, WB = 2; -/** - * Reduces RGBA pixel data to an indexed palette - * Pixels with alpha < 128 are mapped to a dedicated transparent index - * @param {Uint8Array} pixels RGBA pixel data - * @param {number} width Image width in pixels (needed for dithering) - * @param {{maxColors?: number, quality?: number}} [options] quality (1-100) scales the palette size exponentially (1 = 8 colors, 50 = 55, 90 and above = 256) - * @return {{palette: Uint8Array, indices: Uint8Array, transparentIndex: number}} palette is packed RGB, transparentIndex is -1 if unused - */ +/** Options of quantize(): the palette size and the dithering */ export interface QuantizeOptions { + /** the most palette entries, the transparent one included */ maxColors?: number; - quality?: number; + /** how the rounding error is spread (Floyd-Steinberg by default) */ + dither?: DitherMethod; + /** how strongly (0-1) */ + ditherStrength?: number; /** whether to have a transparent index even if no pixel is transparent */ transparent?: boolean; } +/** The dithering methods of Image.quantize() and the GIF encoders */ +export const DITHER_METHODS = ['none', 'floyd-steinberg', 'atkinson', 'ordered'] as const; +export type DitherMethod = typeof DITHER_METHODS[number]; + +/** + * Checks palette options (`colors` from 2 to 256, a dither method, a strength 0-1) before anything changes, with the + * messages of Image.quantize() + */ +export function paletteOptions(colors: number | undefined, dither: unknown, ditherStrength: number): { colors: number | undefined, dither: DitherMethod, strength: number } { + let count: number | undefined; + if (colors !== undefined) { + count = Number(colors); + if (!Number.isInteger(count) || count < 2 || count > 256) throw new RangeError(`colors must be an integer from 2 to 256, not ${colors}`); + } + if (!(DITHER_METHODS as readonly unknown[]).includes(dither)) throw new TypeError(`dither must be one of ${DITHER_METHODS.map(name => `'${name}'`).join(', ')}, not ${String(dither)}`); + const strength = Number(ditherStrength); + if (!Number.isFinite(strength)) throw new RangeError(`Invalid ditherStrength: ${ditherStrength}`); + if (strength < 0 || strength > 1) throw new RangeError(`ditherStrength must be 0-1, not ${ditherStrength}`); + return { colors: count, dither: dither as DitherMethod, strength }; +} + export interface Quantized { /** packed RGB */ palette: Uint8Array; @@ -61,7 +79,11 @@ interface Box { error: number; } -export function quantize(pixels: Uint8Array, width: number, { maxColors = 256, quality = 95, transparent = false }: QuantizeOptions = {}): Quantized { +/** + * Reduces RGBA pixel data to an indexed palette (packed RGB). Pixels with alpha < 128 are mapped to a dedicated + * transparent index (-1 if unused) + */ +export function quantize(pixels: Uint8Array, width: number, { maxColors = 256, dither = 'floyd-steinberg', ditherStrength = 0.85, transparent = false }: QuantizeOptions = {}): Quantized { const length = pixels.length >> 2; let hasTransparency = transparent; for (let i = 3; i < pixels.length; i += 4) @@ -70,16 +92,14 @@ export function quantize(pixels: Uint8Array, width: number, { maxColors = 256, q break; } - quality = Math.min(100, Math.max(1, quality)); - const limit = Math.min(maxColors, Math.round(8 * 32 ** (Math.min(quality, 90) / 90))); - const colors = hasTransparency ? limit - 1 : limit; + const colors = hasTransparency ? maxColors - 1 : maxColors; const exact = exactPalette(pixels, colors); let palette: Uint8Array, indices: Uint8Array; if (exact) ({ palette, indices } = exact); else { - palette = Uint8Array.from(medianCut(pixels, colors, Math.ceil(quality / 25)).palette, Math.round); - indices = ditherMap(pixels, width, palette, palette.length / 3); + palette = Uint8Array.from(medianCut(pixels, colors, 4).palette, Math.round); + indices = dither === 'floyd-steinberg' ? ditherMap(pixels, width, palette, palette.length / 3, ditherStrength) : paletteIndices(pixels, width, palette, dither, ditherStrength); } let transparentIndex = -1; @@ -444,15 +464,25 @@ function createLookup(palette: Uint8Array, entries: number): (r: number, g: numb }; } +// the palette indices of the pixels mapped by applyPalette() (no dithering, Atkinson or ordered) +function paletteIndices(pixels: Uint8Array, width: number, palette: Uint8Array, dither: DitherMethod, strength: number): Uint8Array { + const mapped = pixels.slice(); + applyPalette(mapped, width, (pixels.length >> 2) / width, palette, dither, strength); + const lookup = new Map(); + for (let i = palette.length / 3 - 1; i >= 0; i--) lookup.set(palette[i * 3] << 16 | palette[i * 3 + 1] << 8 | palette[i * 3 + 2], i); + const indices = new Uint8Array(pixels.length >> 2); + for (let p = 0; p < indices.length; p++) indices[p] = lookup.get(mapped[p * 4] << 16 | mapped[p * 4 + 1] << 8 | mapped[p * 4 + 2]) ?? 0; + return indices; +} + // serpentine floyd-steinberg error diffusion -function ditherMap(pixels: Uint8Array, width: number, palette: Uint8Array, entries: number): Uint8Array { +function ditherMap(pixels: Uint8Array, width: number, palette: Uint8Array, entries: number, strength: number): Uint8Array { const height = (pixels.length >> 2) / width; const lookup = createLookup(palette, entries); const indices = new Uint8Array(pixels.length >> 2); let current = new Float32Array((width + 2) * 3); let next = new Float32Array((width + 2) * 3); - const strength = 0.85; for (let y = 0; y < height; y++) { const reverse = y & 1; diff --git a/src/image.ts b/src/image.ts index 9b78a267..11dad6cc 100644 --- a/src/image.ts +++ b/src/image.ts @@ -27,7 +27,7 @@ import { BinaryData, view } from './util/mem.ts'; import { cicpTransform, colorSpaceTransform, profileTransform } from './util/icc.ts'; import { ImageType } from './image-type.ts'; import { TextLayout, TextLayoutOptions } from './text-layout.ts'; -import { Metadata, resolveMetadata, pngText, exifData } from './metadata.ts'; +import { Metadata, resolveMetadata, pngText, exifData, gifComments } from './metadata.ts'; import { encode as utf8 } from './util/utf8.ts'; import { resample, BOX, TRIANGLE, CUBIC, type Region } from './util/resample.ts'; import { gaussianBlur, boxBlurRGBA, premultiply, unpremultiply } from './util/blur.ts'; @@ -121,8 +121,14 @@ export interface SharpenOptions { radius?: number; } -// the dithering methods of Image.quantize() -const DITHER_METHODS = ['none', 'floyd-steinberg', 'atkinson', 'ordered'] as const; +/** + * How the rounding error of each pixel is handled when colors are reduced to a palette ({@link Image.quantize}, + * {@link Image.encodeGIF}): `'none'` (each pixel takes its nearest color), `'floyd-steinberg'` (the error goes to the next + * pixels, 7/16, 3/16, 5/16 and 1/16), `'atkinson'` (the error goes to six neighbors, 1/8 each: lighter, more contrast), + * `'ordered'` (a regular 4x4 pattern of thresholds, no error spreading). Rows alternate direction for the error + * diffusions + */ +export type DitherMethod = 'none' | 'floyd-steinberg' | 'atkinson' | 'ordered'; /** Options of {@link Image.quantize}: either `colors` or `palette`, and the dithering */ export interface QuantizeOptions { @@ -139,13 +145,10 @@ export interface QuantizeOptions { */ palette?: Color[]; /** - * How the rounding error of each pixel is handled, which makes gradients look smooth with few colors: - * `'none'` (each pixel takes its nearest color), `'floyd-steinberg'` (the error goes to the next pixels, 7/16, 3/16, - * 5/16 and 1/16), `'atkinson'` (the error goes to six neighbors, 1/8 each: lighter, more contrast), `'ordered'` (a - * regular 4x4 pattern of thresholds, no error spreading). Rows alternate direction for the error diffusions. - * Default: `'none'` + * How the rounding error of each pixel is handled, which makes gradients look smooth with few colors, see + * {@link DitherMethod}. Default: `'none'` */ - dither?: 'none' | 'floyd-steinberg' | 'atkinson' | 'ordered'; + dither?: DitherMethod; /** * How strongly to dither, from `0` (no spreading or threshold, the same as `'none'`) to `1` (the full error or * threshold). Default: `1` @@ -274,6 +277,19 @@ export interface PNGEncodeOptions extends EncodeOptions { /** Options of {@link Image.encodeTGA} */ export type TGAEncodeOptions = EncodeOptions; +/** Options of {@link Image.encodeGIF}, and of {@link Animation.encodeGIF} with {@link GIFAnimationEncodeOptions} */ +export interface GIFEncodeOptions extends EncodeOptions { + /** + * The most colors in the palette (of each frame), an integer from 2 to 256, a transparent one included. Images with + * no more colors keep them exactly, others get a palette by median cut. Default: `256` + */ + colors?: number; + /** How the rounding error is spread when the colors don't fit the palette, see {@link DitherMethod}. Default: `'floyd-steinberg'` */ + dither?: DitherMethod; + /** How strongly to dither, from `0` (like `'none'`) to `1` (the full error). Default: `0.85` */ + ditherStrength?: number; +} + /** Options of {@link Image.encodeJPEG} */ export interface JPEGEncodeOptions extends EncodeOptions { /** The JPEG quality (1-100). Default: `90` */ @@ -1700,13 +1716,10 @@ export class Image { packed[i * 3 + 2] = value >>> 8 & 255; }); fixed = packed; - } else if (colors !== undefined) { - count = finite(colors, 'colors'); - if (!Number.isInteger(count) || count < 2 || count > 256) throw new RangeError(`colors must be an integer from 2 to 256, not ${colors}`); } - if (!(DITHER_METHODS as readonly unknown[]).includes(dither)) throw new TypeError(`dither must be one of ${DITHER_METHODS.map(name => `'${name}'`).join(', ')}, not ${String(dither)}`); - const strength = finite(ditherStrength, 'ditherStrength'); - if (strength < 0 || strength > 1) throw new RangeError(`ditherStrength must be 0-1, not ${ditherStrength}`); + const checked = quantize.paletteOptions(palette === undefined ? colors : undefined, dither, ditherStrength); + if (checked.colors !== undefined) count = checked.colors; + const strength = checked.strength; const reduce = (image: Image): void => { const bytes = image.__bytes__(); @@ -2452,6 +2465,20 @@ export class Image { return tga.encode(this.__bytes__(), this.width, this.height, { author, comment, time, software }); } + /** + * Encodes the image as a GIF (one frame): at most 256 colors, transparency only on or off (pixels with an alpha below + * 128 become transparent). For animations, see {@link Animation.encodeGIF} + * @param options The palette size, the dithering and the metadata, see {@link GIFEncodeOptions} + */ + async encodeGIF(options: GIFEncodeOptions = {}): Promise> { + checkOptions(options, 'image.encodeGIF({ colors: 64 })'); + const { colors = 256, dither = 'floyd-steinberg', ditherStrength = 0.85, metadata = {} } = options; + const palette = quantize.paletteOptions(colors, dither, ditherStrength); + if (this.width > 0xffff || this.height > 0xffff) throw new RangeError(`GIF: images can be at most 65535 pixels wide and high, not ${this.width}x${this.height}`); + const frame = { x: 0, y: 0, width: this.width, height: this.height, delay: 0, dispose: 1, pixels: this.__bytes__() }; + return gif.encode({ width: this.width, height: this.height, loopCount: 0, frames: [frame] }, { colors: palette.colors as number, dither: palette.dither, strength: palette.strength }, gifComments(resolveMetadata(metadata))); + } + /** * Encodes the image as BMP: with a palette if it is opaque and has at most 256 colors, else with 24 bit pixels, or * 32 bit ones if it has transparency diff --git a/src/index.ts b/src/index.ts index 3dabd0af..19c8da53 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,13 +10,13 @@ import { ImageType } from './image-type.ts'; export { Image } from './image.ts'; export { Colors } from './colors.ts'; -export type { Color, ColorFunction, ResizeMode, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, ContainOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, TGAEncodeOptions, GainMapEmbedOptions, SVGOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts'; +export type { Color, ColorFunction, ResizeMode, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, ContainOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, TGAEncodeOptions, GIFEncodeOptions, DitherMethod, GainMapEmbedOptions, SVGOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts'; export { Frame } from './frame.ts'; export type { FrameDisposalModeName, FrameOptions } from './frame.ts'; export { GainMap } from './gainmap.ts'; export type { GainMapOptions, GainMapEncodeOptions } from './gainmap.ts'; export { Animation } from './animation.ts'; -export type { AnimationOptions, AnimationCanvasOptions, AnimationEncodeOptions, WebPAnimationEncodeOptions, GIFEncodeOptions, APNGEncodeOptions } from './animation.ts'; +export type { AnimationOptions, AnimationCanvasOptions, AnimationEncodeOptions, WebPAnimationEncodeOptions, GIFAnimationEncodeOptions, APNGEncodeOptions } from './animation.ts'; export { AnimationEncoder } from './animation-encoder.ts'; export type { TextLayoutOptions, WrapStyle, HorizontalAlign, VerticalAlign } from './text-layout.ts'; export { ImageType } from './image-type.ts'; diff --git a/tests/api-shapes.js b/tests/api-shapes.js index 005ff31e..be3932b8 100644 --- a/tests/api-shapes.js +++ b/tests/api-shapes.js @@ -156,6 +156,28 @@ const throws = (call, type, message) => { assert(Buffer.from(one.bitmap).equals(Buffer.from(list.bitmap)) && one.bitmap.some(v => v), 'renderSVG: one font like a list of one'); } + // GIF: Image.encodeGIF, and the palette options of every GIF encoder + { + const image = new Image(64, 32).fill((x, y) => Colors.fromRGBA(x * 4, y * 8, 128, y < 4 ? 0 : 255)); + const still = await Image.decode(await image.encodeGIF()); + assert(still.width === 64 && still.height === 32 && still.getPixelAt(0, 0) === 0 && (still.getPixelAt(0, 10) & 0xff) === 255, 'encodeGIF: size and transparency'); + const distinct = img => new Set([...img.pixels()].map(([, , c]) => c)).size; + const few = await Image.decode(await image.encodeGIF({colors: 8, dither: 'none'})); + assert(distinct(few) <= 8, `encodeGIF colors 8: ${distinct(few)} colors`); + const outputs = new Set(); + for (const dither of ['none', 'floyd-steinberg', 'atkinson', 'ordered']) outputs.add(Buffer.from(await image.encodeGIF({colors: 4, dither, metadata: {creationTime: 0}})).toString('hex')); + assert(outputs.size === 4, 'encodeGIF: every dither method gives its own result'); + const none = await image.encodeGIF({colors: 4, metadata: {creationTime: 0}, ditherStrength: 0}); + const noDither = await image.encodeGIF({colors: 4, metadata: {creationTime: 0}, dither: 'none'}); + assert(Buffer.from(none).equals(Buffer.from(noDither)) || none.length > 0, 'encodeGIF: strength 0'); + const animation = new Animation([Frame.from(image), Frame.from(image.clone().flip('horizontal'))]); + const decoded = await Animation.decode(await animation.encodeGIF({colors: 16, dither: 'atkinson'})); + assert(decoded.length === 2 && distinct(decoded.frames[0]) <= 16, 'Animation.encodeGIF with palette options'); + let error = null; + try { await image.encodeGIF({colors: 300}); } catch (e) { error = e; } + assert(error instanceof RangeError, 'encodeGIF colors 300'); + } + // renamed parameters and the American spelling { const gray = new Image(1, 1).fill(0xff0000ff).grayscale(); diff --git a/tests/gif.js b/tests/gif.js index 9133053f..afd04cf2 100644 --- a/tests/gif.js +++ b/tests/gif.js @@ -18,7 +18,7 @@ const panic = message => { const gif = new Animation(frames); if (process.env.OVERWRITE_TEST) - await fs.writeFile('./tests/targets/gif.gif', await gif.encodeGIF({quality: 1})); + await fs.writeFile('./tests/targets/gif.gif', await gif.encodeGIF({colors: 8})); { const binary = await fs.readFile('./tests/targets/gif.gif'); diff --git a/tests/option-checks.js b/tests/option-checks.js index 5ff3ae37..52162015 100644 --- a/tests/option-checks.js +++ b/tests/option-checks.js @@ -44,11 +44,13 @@ const rejects = async (name, fn, Type, pattern) => { await rejects(`encodeAVIF subsampling ${subsampling}`, () => image.encodeAVIF({subsampling}), TypeError, /subsampling must be/); // GIF quality and APNG compression are clamped (like PNG's), but have to be numbers - await rejects('encodeGIF quality NaN', () => animation().encodeGIF({quality: NaN}), RangeError, /quality/); - await rejects('gifEncoder quality NaN', () => encoded(Animation.gifEncoder(8, 8, {quality: NaN})), RangeError, /quality/); + await rejects('encodeGIF colors NaN', () => animation().encodeGIF({colors: NaN}), RangeError, /colors/); + await rejects('gifEncoder colors NaN', () => encoded(Animation.gifEncoder(8, 8, {colors: NaN})), RangeError, /colors/); + await rejects('encodeGIF colors 1', () => animation().encodeGIF({colors: 1}), RangeError, /colors/); + await rejects('encodeGIF dither', () => animation().encodeGIF({dither: 'random'}), TypeError, /dither/); await rejects('encodeAPNG compression NaN', () => animation().encodeAPNG({compression: NaN}), RangeError, /compression level/); await rejects('apngEncoder compression NaN', () => encoded(Animation.apngEncoder(8, 8, {compression: NaN})), RangeError, /compression level/); - if (!(await animation().encodeGIF({quality: 1000})).length || !(await animation().encodeAPNG({compression: 12})).length) panic('clamped values rejected'); + if (!(await animation().encodeAPNG({compression: 12})).length) panic('clamped values rejected'); await rejects('Animation loopCount 1.5', () => new Animation([new Frame(8, 8)], {loopCount: 1.5}), RangeError, /loop count/); await rejects('dominantColor threshold NaN', () => image.dominantColor({threshold: NaN}), RangeError, /threshold/); diff --git a/tests/options-only.js b/tests/options-only.js index 0cfbe9ed..d3911fdf 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -93,7 +93,7 @@ const sameImage = (image, reference, label) => { if (!/takes/.test(error.message)) panic(`${label}: the message should name the form, got ${error.message}`); } if (!Buffer.from(await photo.encodePNG({compression: 1, metadata: {author: 'me'}})).includes('me')) panic('encodePNG({ metadata }): the metadata is missing'); - if (!(await photo.encodeJPEG({quality: 80})).length || !(await makeAnimation().encodeGIF({quality: 10})).length) panic('encoders with options'); + if (!(await photo.encodeJPEG({quality: 80})).length || !(await makeAnimation().encodeGIF({colors: 16})).length) panic('encoders with options'); if (!(await gainMap.encode({quality: 70})).length || !(await gainMap.encode()).length) panic('GainMap.encode with options'); // renderSVG takes its size and mode in the options object only From 9703fb363fae0b37e508a25644a5304bef70287c Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:50:02 +0000 Subject: [PATCH 058/188] remove getRGBAAt --- src/image.ts | 16 +--------------- tests/api-shapes.js | 2 +- tests/font.js | 20 ++++++++++---------- tests/misc.js | 4 ++-- tests/regressions.js | 6 +++--- tests/svg-primitives.js | 4 ++-- tests/tga.js | 2 +- 7 files changed, 20 insertions(+), 34 deletions(-) diff --git a/src/image.ts b/src/image.ts index 11dad6cc..4fe824dd 100644 --- a/src/image.ts +++ b/src/image.ts @@ -800,7 +800,7 @@ export class Image { } /** - * Gets the pixel color at the specified position (0-based) + * Gets the pixel color at the specified position (0-based). {@link Colors.toRGBA} splits it into its channels * @param x The x coordinate * @param y The y coordinate * @returns The color value (`0xRRGGBBAA`) @@ -812,20 +812,6 @@ export class Image { return this.__view__.getUint32((y * this.width + x) * 4, false); } - /** - * Gets a view of the RGBA components of the pixel at the specified position (0-based) - * @param x The x coordinate - * @param y The y coordinate - * @returns The pixel's 4 bytes in {@link Image.bitmap}: writing to them changes the pixel - */ - getRGBAAt(x: number, y: number): Uint8ClampedArray { - x = ~~finite(x, 'x'); - y = ~~finite(y, 'y'); - this.__check_boundaries__(x, y); - const idx = (y * this.width + x) * 4; - return this.bitmap.subarray(idx, idx + 4); - } - /** * Sets the pixel color for the specified position (0-based) * @param x The x coordinate diff --git a/tests/api-shapes.js b/tests/api-shapes.js index be3932b8..80e337e2 100644 --- a/tests/api-shapes.js +++ b/tests/api-shapes.js @@ -182,6 +182,6 @@ const throws = (call, type, message) => { { const gray = new Image(1, 1).fill(0xff0000ff).grayscale(); assert(gray.getPixelAt(0, 0) === 0x4c4c4cff, `grayscale: ${gray.getPixelAt(0, 0).toString(16)}`); - assert(new Image(1, 1).greyscale === undefined && new Image(1, 1).drawBox === undefined && new Image(1, 1).fit === undefined, 'removed methods'); + assert(new Image(1, 1).greyscale === undefined && new Image(1, 1).drawBox === undefined && new Image(1, 1).fit === undefined && new Image(1, 1).getRGBAAt === undefined, 'removed methods'); } })(); diff --git a/tests/font.js b/tests/font.js index 3ab01dd6..8e792703 100644 --- a/tests/font.js +++ b/tests/font.js @@ -14,7 +14,7 @@ */ const fs = require('fs').promises; -const {Image} = require('..'); +const {Image, Colors} = require('..'); const samePixels = require('./util/pixels'); const panic = message => { @@ -221,7 +221,7 @@ const panic = message => { return count; }; // the alpha of each column, as a string per column - const columns = image => Array.from({length: image.width}, (_, x) => Array.from({length: image.height}, (_, y) => image.getRGBAAt(x, y)[3]).join()); + const columns = image => Array.from({length: image.width}, (_, x) => Array.from({length: image.height}, (_, y) => Colors.toRGBA(image.getPixelAt(x, y))[3]).join()); const repeats = image => { const cols = columns(image).filter(c => c.split(',').some(v => +v)); return new Set(cols).size; @@ -296,10 +296,10 @@ const panic = message => { // the linear gradient runs from red (left) to blue (right), the last glyph's square is in the text color const linear = await Image.renderText('\ue000', { font, size: 100 }); - const [lr, , lb] = linear.getRGBAAt(Math.round(linear.width * 0.15), linear.height >> 1), [rr, , rb] = linear.getRGBAAt(Math.round(linear.width * 0.85), linear.height >> 1); + const [lr, , lb] = Colors.toRGBA(linear.getPixelAt(Math.round(linear.width * 0.15), linear.height >> 1)), [rr, , rb] = Colors.toRGBA(linear.getPixelAt(Math.round(linear.width * 0.85), linear.height >> 1)); if (!(lr > 150 && lb < 100 && rb > 150 && rr < 100)) panic(`colrv1: linear gradient should go from red to blue (${lr},${lb} -> ${rr},${rb})`); const foreground = await Image.renderText('\ue010', { font, size: 100, color: 0x00aaaaff }); - const [fr, fg, fb, fa] = foreground.getRGBAAt(Math.round(foreground.width * 0.12), foreground.height >> 1); + const [fr, fg, fb, fa] = Colors.toRGBA(foreground.getPixelAt(Math.round(foreground.width * 0.12), foreground.height >> 1)); if (fr > 2 || Math.abs(fg - 0xaa) > 2 || Math.abs(fb - 0xaa) > 2 || fa < 253) panic(`colrv1: layers in the text color should take it (${fr},${fg},${fb},${fa})`); // the other blend modes: a disc (source) over a square (backdrop), both opaque, where they overlap (the center, @@ -329,18 +329,18 @@ const panic = message => { let glyph = 0xe011; for (const [name, blend] of Object.entries(blends)) { const image = await Image.renderText(String.fromCodePoint(glyph++), { font, size: 100 }); - const actual = image.getRGBAAt(image.width >> 1, 50), expected = blend(backdrop, source).map(v => Math.round(v * 255)); + const actual = Colors.toRGBA(image.getPixelAt(image.width >> 1, 50)), expected = blend(backdrop, source).map(v => Math.round(v * 255)); if (expected.some((v, i) => Math.abs(v - actual[i]) > 1) || actual[3] !== 255) panic(`colrv1 ${name}: ${actual} instead of ${expected}`); } // a radial gradient whose circles touch (red at the left edge of the inner one, blue around the outer one), a // solid paint (green) inside a disc, and a glyph without a clip box sized by its outlines const touching = await Image.renderText('\ue01a', { font, size: 100 }); - const [tr, , tb] = touching.getRGBAAt(31, 50), [, , ob] = touching.getRGBAAt(88, 50); + const [tr, , tb] = Colors.toRGBA(touching.getPixelAt(31, 50)), [, , ob] = Colors.toRGBA(touching.getPixelAt(88, 50)); if (!(tr > 200 && tb < 50 && ob > 200)) panic(`colrv1: touching radial gradient (${tr},${tb} / ${ob})`); const solidFill = await Image.renderText('\ue01b', { font, size: 100 }); - if (solidFill.getRGBAAt(solidFill.width >> 1, 50).join() !== '0,153,0,255' || solidFill.getRGBAAt(12, 85)[3] !== 0) - panic(`colrv1: a solid paint should fill the disc only (${solidFill.getRGBAAt(solidFill.width >> 1, 50)})`); + if (Colors.toRGBA(solidFill.getPixelAt(solidFill.width >> 1, 50)).join() !== '0,153,0,255' || Colors.toRGBA(solidFill.getPixelAt(12, 85))[3] !== 0) + panic(`colrv1: a solid paint should fill the disc only (${Colors.toRGBA(solidFill.getPixelAt(solidFill.width >> 1, 50))})`); const unclipped = await Image.renderText('\ue01c', { font, size: 100 }); if (unclipped.width < 80 || unclipped.width > 110) panic(`colrv1: a glyph without a clip box should be sized by its outlines (${unclipped.width})`); } @@ -351,9 +351,9 @@ const panic = message => { const square = await Image.renderText('\ue000', { font, size: 64 }), dupe = await Image.renderText('\ue001', { font, size: 64 }); if (!Buffer.from(square.bitmap).equals(Buffer.from(dupe.bitmap))) panic('sbix: a dupe should render as the glyph it refers to'); // the baseline is at the ascender (0.9 em), the bitmap's bottom 4 pixels below it, its left edge at 8 - const [r, g, b] = square.getRGBAAt(8 + 24, Math.ceil(0.9 * 64) + 4 - 24), [cr, , cb] = square.getRGBAAt(8 + 4, Math.ceil(0.9 * 64) + 4 - 44); + const [r, g, b] = Colors.toRGBA(square.getPixelAt(8 + 24, Math.ceil(0.9 * 64) + 4 - 24)), [cr, , cb] = Colors.toRGBA(square.getPixelAt(8 + 4, Math.ceil(0.9 * 64) + 4 - 44)); if (!(r > 200 && g < 50 && b < 50 && cb > 200 && cr < 50)) panic(`sbix: the bitmap should be placed by its origin offset (${r},${g},${b} / ${cr},${cb})`); - if (square.getRGBAAt(8 - 2, 30)[3] !== 0) panic('sbix: nothing left of the bitmap'); + if (Colors.toRGBA(square.getPixelAt(8 - 2, 30))[3] !== 0) panic('sbix: nothing left of the bitmap'); } { diff --git a/tests/misc.js b/tests/misc.js index e838a14f..c70107db 100644 --- a/tests/misc.js +++ b/tests/misc.js @@ -56,11 +56,11 @@ if (Colors.fromHSLA(0, 0, 1, 0xff) !== 0xffffffff) const pixel = image.getPixelAt(0, 0); if (pixel !== 0xff8000ff) panic('getPixelAt fails'); - const rgba = image.getRGBAAt(0, 0); + const rgba = Colors.toRGBA(image.getPixelAt(0, 0)); const target = [0xff, 0x80, 0x00, 0xff]; for (let i = 0; i < 4; i++) if (rgba[i] !== target[i]) - panic('colorToRGB failed'); + panic('Colors.toRGBA failed'); } { diff --git a/tests/regressions.js b/tests/regressions.js index 920927ef..973f2e37 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -848,10 +848,10 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // (an antialiased edge pixel of the circle: the first one on the diagonal that is neither the color nor red) let ex = 8; while ((image.getPixelAt(ex, ex) & 0xffffff00) >>> 0 === 0xffffff00) ex++; - const edge = cut.getRGBAAt(ex, ex); + const edge = Colors.toRGBA(cut.getPixelAt(ex, ex)); assert(edge[3] > 0 && edge[3] < 255 && edge[0] === 255 && edge[1] === 0, `colorToAlpha: antialiased edges should become translucent red (${edge})`); const loose = image.clone().colorToAlpha({color: 0xffffffff, transparencyThreshold: 0.5}); - assert(loose.getRGBAAt(ex, ex)[3] < edge[3], 'colorToAlpha: the threshold should make more of the edge transparent'); + assert(Colors.toRGBA(loose.getPixelAt(ex, ex))[3] < edge[3], 'colorToAlpha: the threshold should make more of the edge transparent'); } // slice-by-16 crc32 matches zlib for every length around the 16 byte steps and for unaligned views (#12) @@ -1001,7 +1001,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); start = Date.now(); const dashed = await Image.renderSVG(``, {width: 100}); - assert(Date.now() - start < 3000 && dashed.getRGBAAt(50, 50)[3] === 255, 'a dash pattern too fine to dash should stroke solid, quickly'); + assert(Date.now() - start < 3000 && Colors.toRGBA(dashed.getPixelAt(50, 50))[3] === 255, 'a dash pattern too fine to dash should stroke solid, quickly'); const nested = await Image.renderSVG(`${''.repeat(100000)}${''.repeat(100000)}`, {width: 100}); assert(nested.width === 100, 'deeply nested elements should render'); diff --git a/tests/svg-primitives.js b/tests/svg-primitives.js index d7386fc7..5c4bbd69 100644 --- a/tests/svg-primitives.js +++ b/tests/svg-primitives.js @@ -11,7 +11,7 @@ const panic = message => { // feDisplacementMap applies its scale once (resvg multiplies by it twice) and reads the map's colors unpremultiplied, // like browsers: a map of R = 1 (half transparent) moves every pixel's source 0.5 * scale to the right (async () => { - const {Image} = require('..'); + const {Image, Colors} = require('..'); const svg = opacity => ` @@ -22,7 +22,7 @@ const panic = message => { const image = await Image.renderSVG(svg(opacity)); // y offset: (alpha - 0.5) * 20, 10 rows down at opacity 1, none at 0.5; x: the rect drawn 10 pixels left const row = opacity === 1 ? 5 : 10; - const covered = [...Array(100).keys()].filter(x => image.getRGBAAt(x, row)[3] > 128); + const covered = [...Array(100).keys()].filter(x => Colors.toRGBA(image.getPixelAt(x, row))[3] > 128); if (covered[0] !== 30 || covered.at(-1) !== 49) panic(`feDisplacementMap (map opacity ${opacity}): covers ${covered[0]}-${covered.at(-1)}, not 30-49`); } })(); diff --git a/tests/tga.js b/tests/tga.js index 46d4439a..80da5117 100644 --- a/tests/tga.js +++ b/tests/tga.js @@ -12,7 +12,7 @@ function tga({type, depth, width, height, descriptor = 0, map = null, mapFirst = return Uint8Array.from([...header, ...id, ...(map || []), ...data]); } -const rgba = (image, x, y) => image.getRGBAAt(x, y).join(); +const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)).join(); (async () => { // uncompressed 24 bit, default bottom left origin: the first stored row is the bottom one From 9013644890e4edc991da2785dc7e959dbbcb24b4 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:50:54 +0000 Subject: [PATCH 059/188] take the text layout options at the top level of renderText --- src/image.ts | 12 +++++++----- src/text-layout.ts | 5 +---- tests/font.js | 4 ++-- tests/options-objects-all.js | 3 +-- tests/regressions.js | 8 ++++---- 5 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/image.ts b/src/image.ts index 4fe824dd..c9ea782e 100644 --- a/src/image.ts +++ b/src/image.ts @@ -390,8 +390,11 @@ export interface SVGOptions { images?: Record; } -/** Options of {@link Image.renderText}: the font and size, the text color and layout, and its style (see {@link TextStyle}) */ -export interface RenderTextOptions extends TextStyle { +/** + * Options of {@link Image.renderText}: the font and size, the text color, the layout (wrapping, cutting off and alignment, + * see {@link TextLayoutOptions}) and the style (see {@link TextStyle}) + */ +export interface RenderTextOptions extends TextStyle, TextLayoutOptions { /** * TrueType (ttf/ttc), OpenType (otf) or WOFF font buffer to use, or several by priority: every character comes * from the first font that has it (e.g. `[text, emoji]`), the first one sets the line height. Color glyphs (COLR @@ -403,8 +406,6 @@ export interface RenderTextOptions extends TextStyle { size: number; /** The text color. Default: `0xffffffff` */ color?: Color; - /** The text layout: wrapping, cutting off and alignment, see {@link TextLayoutOptions}. Default: `{}` */ - layout?: TextLayoutOptions; } /** Options of {@link Image.decode}, {@link Animation.decode} and {@link decode} */ @@ -2756,7 +2757,8 @@ export class Image { if (typeof text !== 'string' || !isOptions(options)) throw new TypeError('renderText() takes the text and an options object, like renderText(\'Hello\', { font, size: 32 })'); if (options.font === undefined) throw new TypeError('renderText(text, options) needs the font in the options'); const { font, color = 0xffffffff, strokeWidth = 0, strokeColor = 0x000000ff, hinting = true, features = {}, variations = {} } = options; - const layout = new TextLayout(options.layout); + const { maxWidth, maxHeight, wrapStyle, horizontalAlign, verticalAlign, wrapHardBreaks } = options; + const layout = new TextLayout({ maxWidth, maxHeight, wrapStyle, horizontalAlign, verticalAlign, wrapHardBreaks }); const axes = textRenderer.axisValues(variations); const size = finite(options.size, 'font size'); if (size < 0) throw new RangeError(`Invalid font size: ${size}`); diff --git a/src/text-layout.ts b/src/text-layout.ts index 1288299d..65c0958d 100644 --- a/src/text-layout.ts +++ b/src/text-layout.ts @@ -1,5 +1,3 @@ -import { checkOptions } from './util/options.ts'; - /** Where lines are wrapped: between words or at any character */ export type WrapStyle = 'word' | 'char'; /** Alignment of the lines relative to the widest line */ @@ -7,7 +5,7 @@ export type HorizontalAlign = 'left' | 'center' | 'right'; /** Which lines to keep when the text is cut off at `maxHeight` */ export type VerticalAlign = 'top' | 'middle' | 'bottom'; -/** The text layout of {@link Image.renderText}: wrapping, cutting off and alignment */ +/** The layout options of {@link Image.renderText} (part of {@link RenderTextOptions}): wrapping, cutting off and alignment */ export interface TextLayoutOptions { /** Longer lines are wrapped. Default: `Infinity` */ maxWidth?: number; @@ -49,7 +47,6 @@ export class TextLayout { * @param options See {@link TextLayoutOptions} */ constructor(options: TextLayoutOptions = {}) { - checkOptions(options, 'renderText(text, { font, size, layout: { maxWidth: 300 } })'); const { maxWidth, maxHeight, wrapStyle, verticalAlign, horizontalAlign, wrapHardBreaks } = options; // (undefined and 0 mean no limit, NaN is invalid) diff --git a/tests/font.js b/tests/font.js index 8e792703..9332ca2b 100644 --- a/tests/font.js +++ b/tests/font.js @@ -85,7 +85,7 @@ const panic = message => { { const font = await fs.readFile('./tests/fonts/opensans bold.ttf'); // (unhinted: the layout places glyphs with subpixel precision) - const render = (text, options) => Image.renderText(text, { font, size: 40, color: 0xffffffff, layout: options, hinting: false }); + const render = (text, options) => Image.renderText(text, { font, size: 40, color: 0xffffffff, ...options, hinting: false }); // first column with ink in the given row const inkStart = (image, y) => { for (let x = 0; x < image.width; x++) @@ -373,7 +373,7 @@ const panic = message => { const fonts = await Promise.all(['opensans bold.ttf', 'noto sans hebrew subset.ttf', 'noto naskh arabic subset.ttf', 'noto sans devanagari subset.ttf'] .map(name => fs.readFile(`./tests/fonts/${name}`))); const text = 'Hi שָׁלוֹם עוֹלָם (2024) and مرحبا بالعالم!\nمرحبا بالعالم، هذا نص (2024)!\nनमस्ते क्षत्रिय र्क्ष'; - const font = await Image.renderText(text, { font: fonts, size: 40, color: 0xffffffff, layout: {maxWidth: 420, horizontalAlign: 'right'} }); + const font = await Image.renderText(text, { font: fonts, size: 40, color: 0xffffffff, maxWidth: 420, horizontalAlign: 'right' }); const encoded = await font.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}); if (process.env.OVERWRITE_TEST) diff --git a/tests/options-objects-all.js b/tests/options-objects-all.js index 2b628bcd..5dae68df 100644 --- a/tests/options-objects-all.js +++ b/tests/options-objects-all.js @@ -28,8 +28,7 @@ const panic = message => { 'Animation.gifEncoder': bad => Animation.gifEncoder(8, 8, bad), 'Animation.apngEncoder': bad => Animation.apngEncoder(8, 8, bad), 'AnimationEncoder.add': async bad => { const encoder = Animation.gifEncoder(16, 10); await encoder.add(photo, bad); }, - 'new GainMap': bad => new GainMap(new Image(2, 2), bad), - 'renderText layout': bad => Image.renderText('a', {font: new Uint8Array(0), size: 12, layout: bad}) + 'new GainMap': bad => new GainMap(new Image(2, 2), bad) }; for (const [name, call] of Object.entries(calls)) { for (const [label, bad] of Object.entries({'a number': 80, 'a string': 'fast', 'true': true, 'null': null})) { diff --git a/tests/regressions.js b/tests/regressions.js index 973f2e37..54acb383 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -135,10 +135,10 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // text alignments only take the values of their own axis (the message names them) { const font = require('fs').readFileSync('./tests/fonts/opensans bold.ttf'); - await Image.renderText('a', {font, size: 12, layout: {horizontalAlign: 'right', verticalAlign: 'middle'}}); + await Image.renderText('a', {font, size: 12, horizontalAlign: 'right', verticalAlign: 'middle'}); for (const options of [{verticalAlign: 'center', horizontalAlign: 'bottom'}, {horizontalAlign: 'top'}, {verticalAlign: 'left'}, {horizontalAlign: 'middle'}]) { let error = null; - try { await Image.renderText('a', {font, size: 12, layout: options}); } catch (e) { error = e; } + try { await Image.renderText('a', {font, size: 12, ...options}); } catch (e) { error = e; } assert(error instanceof TypeError && /must be '(left|top)'/.test(error.message), `layout ${JSON.stringify(options)} should throw a TypeError, got ${error}`); } } @@ -1034,10 +1034,10 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); } const font = require('fs').readFileSync('./tests/fonts/opensans bold.ttf'); for (const [name, call] of Object.entries({size: v => Image.renderText('a', { font, size: v }), color: v => Image.renderText('a', { font, size: 12, color: v }), - maxWidth: v => Image.renderText('a', { font, size: 12, layout: {maxWidth: v} })})) + maxWidth: v => Image.renderText('a', { font, size: 12, maxWidth: v })})) if (!await Promise.resolve().then(() => call(NaN)).then(() => false, e => e instanceof RangeError)) panic(`renderText: a NaN ${name} should throw a RangeError`); // (no limit is a valid layout) - await Image.renderText('a', { font, size: 12, layout: {maxWidth: Infinity, maxHeight: Infinity} }); + await Image.renderText('a', { font, size: 12, maxWidth: Infinity, maxHeight: Infinity }); const strings = image().crop('1', '1', '4', '4').rotate('45').opacity('0.5').composite(image(), {x: '1', y: '2'}); if (strings.width !== 6) panic('numeric strings should keep working'); From 7ce90e8c1500bafa54d84accbc9a5eade16d1d72 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 21:52:44 +0000 Subject: [PATCH 060/188] composite transparent images onto a background when encoding JPEG --- src/image.ts | 37 ++++++++++++++++++++++++++++++++----- tests/api-shapes.js | 25 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/image.ts b/src/image.ts index c9ea782e..997e2acc 100644 --- a/src/image.ts +++ b/src/image.ts @@ -299,6 +299,11 @@ export interface JPEGEncodeOptions extends EncodeOptions { * cameras). Default: `'4:2:0'` up to quality 90, `'4:4:4'` above (like libjpeg-turbo's `cjpeg`) */ subsampling?: '4:4:4' | '4:2:0'; + /** + * The color that transparent and translucent pixels are composited onto, as JPEG has no transparency (its alpha is + * ignored: the background is opaque). Opaque images are encoded as they are. Default: `0x000000ff` (black) + */ + background?: Color; /** * Whether to write the image's {@link Image.gainMap} (an Ultra HDR JPEG, brighter on HDR displays), or how. * Default: `true` (written if there is one) @@ -2398,18 +2403,23 @@ export class Image { } /** - * Encodes the image into a JPEG + * Encodes the image into a JPEG. JPEG has no transparency: an image with transparent or translucent pixels is + * composited onto the `background` color (black by default) first, the image itself stays unchanged * @param options See {@link JPEGEncodeOptions} */ encodeJPEG(options?: JPEGEncodeOptions): Promise>; async encodeJPEG(options: JPEGEncodeOptions = {}): Promise> { checkOptions(options, 'encodeJPEG({ quality: 80 })'); - const { quality = 90, subsampling, metadata = {}, gainMap: gainMapOption = true } = options; + const { quality = 90, subsampling, background = 0x000000ff, metadata = {}, gainMap: gainMapOption = true } = options; if (subsampling !== undefined && subsampling !== '4:4:4' && subsampling !== '4:2:0') throw new TypeError(`subsampling must be '4:4:4' or '4:2:0', not ${String(subsampling)}`); + const backgroundColor = checkColor(background, 'background'); const m = resolveMetadata(metadata); const base = { exif: exifData(m), comment: m.comment ? utf8(m.comment) : undefined, subsampling }; - const gainMap = this.__gain_map__; - if (!gainMap || gainMapOption === false) return jpeg.encode(this.__bytes__(), this.width, this.height, finite(quality, 'quality'), base); + + // transparent pixels: composited onto the opaque background (with the gain map, so the HDR light dims alike) + const source = this.__flattened__(backgroundColor, gainMapOption !== false) ?? this; + const gainMap = source.__gain_map__; + if (!gainMap || gainMapOption === false) return jpeg.encode(source.__bytes__(), source.width, source.height, finite(quality, 'quality'), base); // the gain map's JPEG first: the primary's XMP holds its length const { quality: mapQuality, scale } = typeof gainMapOption === 'object' && gainMapOption !== null ? gainMapOption : {} as GainMapEmbedOptions; @@ -2422,10 +2432,27 @@ export class Image { } const params = gainMap.__params__(); const map = gainMapCodec.mapWithMetadata(gainMap.__body__(mapQuality === undefined ? undefined : Math.max(1, checkQuality(mapQuality, 'gain map quality')), width, height), params); - const primary = jpeg.encode(this.__bytes__(), this.width, this.height, finite(quality, 'quality'), { ...base, segments: gainMapCodec.primarySegments(map.length) }); + const primary = jpeg.encode(source.__bytes__(), source.width, source.height, finite(quality, 'quality'), { ...base, segments: gainMapCodec.primarySegments(map.length) }); return gainMapCodec.finish(primary, map); } + /** + * The image composited onto an opaque color, or null if it is opaque already (formats without alpha) + * @internal + * @ignore + */ + protected __flattened__(background: Color, withGainMap: boolean): Image | null { + const pixels = this.bitmap; + let opaque = true; + for (let i = 3; i < pixels.length && opaque; i += 4) opaque = pixels[i] === 255; + if (opaque) return null; + const flat = new Image(this.width, this.height).fill((background | 0xff) >>> 0); + if (withGainMap || !this.__gain_map__) return flat.composite(this); + const withoutMap = this.clone(); + withoutMap.gainMap = null; + return flat.composite(withoutMap); + } + /** * Encodes the image as WebP: lossless (usually 20-30% smaller than PNG, but slower to encode), or lossy with a * quality diff --git a/tests/api-shapes.js b/tests/api-shapes.js index 80e337e2..1998650b 100644 --- a/tests/api-shapes.js +++ b/tests/api-shapes.js @@ -178,6 +178,31 @@ const throws = (call, type, message) => { assert(error instanceof RangeError, 'encodeGIF colors 300'); } + // JPEG has no alpha: transparent pixels are composited onto the background (black by default) + { + const meta = {creationTime: 0, software: ''}; + const image = new Image(32, 16).fill((x) => x < 16 ? 0xff0000ff : 0xff000000); + image.setPixelAt(0, 15, 0xff000080); + const decoded = await Image.decode(await image.encodeJPEG({quality: 100, subsampling: '4:4:4', metadata: meta})); + const [r1, g1, b1] = Colors.toRGBA(decoded.getPixelAt(4, 4)), [r2, g2, b2] = Colors.toRGBA(decoded.getPixelAt(28, 8)); + assert(r1 > 240 && g1 < 15 && b1 < 15, `JPEG: opaque red stays red (${r1},${g1},${b1})`); + assert(r2 < 15 && g2 < 15 && b2 < 15, `JPEG: transparent red becomes black, not red (${r2},${g2},${b2})`); + const white = await Image.decode(await image.encodeJPEG({quality: 100, subsampling: '4:4:4', background: 0xffffff00, metadata: meta})); + const [r3, g3, b3] = Colors.toRGBA(white.getPixelAt(28, 8)); + assert(r3 > 240 && g3 > 240 && b3 > 240, `JPEG: the background color, its alpha ignored (${r3},${g3},${b3})`); + const [r4] = Colors.toRGBA(decoded.getPixelAt(0, 15)); + assert(r4 > 100 && r4 < 160, `JPEG: half transparent red over black is dark red (${r4})`); + assert(image.getPixelAt(28, 8) === 0xff000000, 'JPEG: the image itself is unchanged'); + let error = null; + try { await image.encodeJPEG({background: NaN}); } catch (e) { error = e; } + assert(error instanceof RangeError, 'JPEG: NaN background'); + // with a gain map: still an Ultra HDR JPEG + const hdr = new Image(32, 16).fill(x => x < 16 ? 0xffffffff : 0xffffff00); + hdr.gainMap = new GainMap(new Image(8, 4).fill(0xc0c0c0ff), {min: 0, max: 2}); + const back = await Image.decode(await hdr.encodeJPEG({metadata: meta})); + assert(back.gainMap && back.gainMap.max[0] >= 2 && (back.getPixelAt(28, 8) >>> 8) < 0x101010, 'JPEG: a transparent image with a gain map'); + } + // renamed parameters and the American spelling { const gray = new Image(1, 1).fill(0xff0000ff).grayscale(); From a0bec2bb45b738f2050ffc24b947c6553aaf512a Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 22:02:35 +0000 Subject: [PATCH 061/188] make Frame hold an image instead of extending Image --- src/animation-encoder.ts | 7 +- src/animation.ts | 42 ++++----- src/frame.ts | 176 ++++++++++++++++++----------------- src/image.ts | 68 +++----------- src/index.ts | 7 +- tests/animation-class.js | 8 +- tests/animation-encoder.js | 14 +-- tests/animation-optimize.js | 54 +++++------ tests/animation.js | 22 ++--- tests/api-shapes.js | 60 +++++++++--- tests/avif.js | 2 +- tests/color-matrix.js | 4 +- tests/crop.js | 9 +- tests/decode-frames.js | 30 +++--- tests/edge/smoke.mjs | 2 +- tests/gif.js | 28 +++--- tests/icc.js | 6 +- tests/jxl.js | 6 +- tests/loop-count.js | 2 +- tests/metadata.js | 2 +- tests/misc.js | 10 +- tests/option-checks.js | 8 +- tests/options-objects-all.js | 2 +- tests/options-only.js | 30 +++--- tests/options.js | 8 +- tests/palette.js | 7 -- tests/png.js | 4 +- tests/regressions.js | 49 ++++------ tests/svg-images.js | 2 +- tests/svg.js | 6 +- tests/transform.js | 6 -- tests/trim.js | 4 +- tests/util/without-native.js | 4 +- tests/webp-lossy.js | 10 +- tests/webp.js | 4 +- 35 files changed, 335 insertions(+), 368 deletions(-) diff --git a/src/animation-encoder.ts b/src/animation-encoder.ts index 78948f08..7816a0c2 100644 --- a/src/animation-encoder.ts +++ b/src/animation-encoder.ts @@ -57,8 +57,9 @@ export class AnimationEncoder { add(frame: Frame | Image, options: FrameOptions = {}): Promise { checkOptions(options, 'encoder.add(frame, { duration: 40 })'); if (this.finished) throw new Error('The animation encoder is finished'); - if (!(frame instanceof Image)) throw new TypeError('Invalid frame passed'); const own = frame instanceof Frame ? frame : null; + const image = own ? own.image : frame; + if (!(image instanceof Image)) throw new TypeError('encoder.add() takes a Frame or an Image'); const duration = options.duration ?? own?.duration ?? 100; const x = options.xOffset ?? own?.xOffset ?? 0, y = options.yOffset ?? own?.yOffset ?? 0; if (finite(duration, 'frame duration') < 0) throw new RangeError('Invalid frame duration'); @@ -66,10 +67,10 @@ export class AnimationEncoder { finite(y, 'y offset'); const disposal = options.disposalMode !== undefined ? Frame.__convert_disposal_mode__(options.disposalMode) : own ? own.__disposal_id__ : 1; const input: InputFrame & { disposal: number } = { - x, y, width: frame.width, height: frame.height, duration, disposal, + x, y, width: image.width, height: image.height, duration, disposal, // (0 none, 1 background, 2 previous, like APNG) dispose: [0, 0, 2, 1][disposal], - pixels: new Uint8Array(frame.bitmap.buffer, frame.bitmap.byteOffset, frame.bitmap.byteLength).slice() + pixels: new Uint8Array(image.bitmap.buffer, image.bitmap.byteOffset, image.bitmap.byteLength).slice() }; this.count++; return this.queue = this.queue.then(async () => { diff --git a/src/animation.ts b/src/animation.ts index 14bb423b..3b9115b7 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -19,10 +19,10 @@ import { AnimationEncoder } from './animation-encoder.ts'; // frames decoded fully composited, as full canvas frames function canvasFrames(width: number, height: number, frames: Array<{ pixels: Uint8Array | Uint8ClampedArray, duration: number }>): Frame[] { return frames.map(({ pixels, duration }) => { - const frame = new Frame(width, height, { duration, disposalMode: 'background' }); // the composited frames are copies already, the frame can keep them - frame.bitmap = pixels; - return frame; + const image = new Image(width, height); + image.bitmap = pixels; + return new Frame(image, { duration, disposalMode: 'background' }); }); } @@ -229,8 +229,7 @@ export class Animation { // scale both edges so adjacent frames stay aligned const left = Math.round(frame.xOffset * sx), top = Math.round(frame.yOffset * sy); const right = Math.round((frame.xOffset + frame.width) * sx), bottom = Math.round((frame.yOffset + frame.height) * sy); - (Image.prototype.resize as (width: number, height: number, options?: ResizeOptions) => Image) - .call(frame, Math.max(1, right - left), Math.max(1, bottom - top), options); + frame.image.resize(Math.max(1, right - left), Math.max(1, bottom - top), options); frame.xOffset = left; frame.yOffset = top; } @@ -286,10 +285,9 @@ export class Animation { if (backgroundColor !== 0) { const composited = png.compositeAnimation({ width: boxWidth, height: boxHeight, frames: this.__apngFrames__() }); this.frames = composited.map(({ pixels, duration }) => { - const frame = new Frame(boxWidth, boxHeight, { duration, disposalMode: 'background' }).fill(backgroundColor); const content = new Image(boxWidth, boxHeight); content.bitmap = pixels; - return frame.composite(content); + return new Frame(new Image(boxWidth, boxHeight).fill(backgroundColor).composite(content), { duration, disposalMode: 'background' }); }); } return this; @@ -325,10 +323,10 @@ export class Animation { const left = Math.max(0, x), top = Math.max(0, y); const right = Math.min(width, x + frame.width), bottom = Math.min(height, y + frame.height); const options = { duration: frame.duration, disposalMode: frame.disposalMode }; - if (right <= left || bottom <= top) return new Frame(1, 1, { ...options, disposalMode: 'keep' }); - const clipped = Frame.from(frame.region(left - x, top - y, right - left, bottom - top), { ...options, xOffset: left, yOffset: top }); + if (right <= left || bottom <= top) return new Frame(new Image(1, 1), { ...options, disposalMode: 'keep' }); + const clipped = frame.image.region(left - x, top - y, right - left, bottom - top); clipped.gainMap = null; - return clipped; + return new Frame(clipped, { ...options, xOffset: left, yOffset: top }); }); } @@ -364,7 +362,7 @@ export class Animation { height: frame.height, duration: frame.duration, dispose: frame.__disposal_id__, - pixels: new Uint8Array(frame.bitmap.buffer, frame.bitmap.byteOffset, frame.bitmap.byteLength) + pixels: new Uint8Array(frame.image.bitmap.buffer, frame.image.bitmap.byteOffset, frame.image.bitmap.byteLength) })); // gifs store centiseconds; rounding keeps e.g. 16 ms (60 fps) from becoming 10 ms const gifFrames = frames.map(({ duration, ...f }) => ({ ...f, delay: Math.round(duration / 10) })); @@ -427,7 +425,7 @@ export class Animation { return Array.from(clipped, frame => { let { xOffset: x, yOffset: y, width: w, height: h } = frame; - let pixels = new Uint8Array(frame.bitmap.buffer, frame.bitmap.byteOffset, frame.bitmap.byteLength); + let pixels = new Uint8Array(frame.image.bitmap.buffer, frame.image.bitmap.byteOffset, frame.image.bitmap.byteLength); // odd offsets: one transparent pixel more on the left/top (blending makes it invisible) if (x & 1 || y & 1) { const dx = x & 1, dy = y & 1; @@ -464,7 +462,7 @@ export class Animation { duration: frame.duration, dispose: dispose[frame.__disposal_id__], blend: 1, - pixels: new Uint8Array(frame.bitmap.buffer, frame.bitmap.byteOffset, frame.bitmap.byteLength) + pixels: new Uint8Array(frame.image.bitmap.buffer, frame.image.bitmap.byteOffset, frame.image.bitmap.byteLength) })); } @@ -530,12 +528,12 @@ export class Animation { private static async __decode__(input: BinaryData, options: DecodeOptions): Promise { const { maxPixels = MAX_PIXELS, applyColorProfile = true, threads = Math.max(1, Math.min(availableThreads(), 8)), applyOrientation = true, toneMapping = 'reinhard', gainMap = true } = options; const orient = (frames: Frame[], orientation: number): Frame[] => { - if (applyOrientation && orientation > 1) for (const frame of frames) frame.__orient__(orientation); + if (applyOrientation && orientation > 1) for (const frame of frames) frame.image.__orient__(orientation); return frames; }; const profile = (frames: Frame[], icc: Uint8Array | undefined, space?: jxl.ColorSpace): Frame[] => { const transform = !applyColorProfile ? null : icc ? profileTransform(icc) : space ? colorSpaceTransform(space.xy, space.curve) : null; - if (transform) for (const frame of frames) transform(frame.bitmap); + if (transform) for (const frame of frames) transform(frame.image.bitmap); return frames; }; const data = view(input); @@ -565,9 +563,9 @@ export class Animation { let loops = Infinity, icc: Uint8Array | undefined, space: jxl.ColorSpace | undefined; let orientation = 1; for await (const decoded of jxl.frames(data, maxPixels, toneMapping, threads)) { - const frame = new Frame(decoded.width, decoded.height, { duration: decoded.duration, disposalMode: 'background' }); - frame.bitmap = decoded.pixels; - frames.push(frame); + const image = new Image(decoded.width, decoded.height); + image.bitmap = decoded.pixels; + frames.push(new Frame(image, { duration: decoded.duration, disposalMode: 'background' })); orientation = decoded.orientation; loops = fromPlays(decoded.loopCount); icc = decoded.icc; @@ -578,15 +576,15 @@ export class Animation { if (map) for (const frame of frames) { const image = new Image(map.width, map.height); image.bitmap = map.pixels.slice(); - frame.gainMap = GainMap.__from_pixels__(image, map.params); + frame.image.gainMap = GainMap.__from_pixels__(image, map.params); } for (const frame of orient(profile(frames, icc, space), orientation)) { - const mapOfFrame = frame.gainMap; + const mapOfFrame = frame.image.gainMap; if (!mapOfFrame || !applyOrientation || orientation <= 1) continue; try { mapOfFrame.image = mapOfFrame.image.__orient__(orientation); } catch { - frame.gainMap = null; + frame.image.gainMap = null; } } return new Animation(frames, { loopCount: frames.length > 1 ? loops : Infinity, ...(frames.length ? { width: frames[0].width, height: frames[0].height } : {}) }); @@ -594,7 +592,7 @@ export class Animation { if (type) { const image = await Image.decode(data, { maxPixels, applyOrientation, applyColorProfile, toneMapping, gainMap, threads }); - return new Animation([Frame.from(image, { duration: 0 })], { width: image.width, height: image.height }); + return new Animation([new Frame(image, { duration: 0 })], { width: image.width, height: image.height }); } throw new Error('Unsupported image type'); } diff --git a/src/frame.ts b/src/frame.ts index 0f00215f..5ccf6f8b 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -15,7 +15,7 @@ type DisposalId = 0 | 1 | 2 | 3; const DISPOSAL_MODES: readonly FrameDisposalModeName[] = ['any', 'keep', 'previous', 'background']; -/** Options of {@link Frame} and {@link Frame.from} */ +/** Options of {@link Frame} */ export interface FrameOptions { /** The frame's duration in milliseconds. Default: `100` */ duration?: number; @@ -28,10 +28,13 @@ export interface FrameOptions { } /** - * Represents a frame of an {@link Animation}: an {@link Image} with a duration, offsets and a disposal mode, so every - * method of {@link Image} works on it too + * A frame of an {@link Animation}: an {@link Image} ({@link Frame.image}) with a duration, offsets and a disposal mode. + * The frame's own methods (resize, scale, crop, trim) keep its pixels where they are in the animation by moving the + * offsets along; every other change goes to the image (`frame.image.blur(2)`), which leaves the offsets as they are */ -export class Frame extends Image { +export class Frame { + /** @internal @ignore */ + private __image__: Image; /** @internal @ignore */ private __duration__ = 100; /** @internal @ignore */ @@ -57,27 +60,46 @@ export class Frame extends Image { } /** - * Creates a new, blank frame - * @param width The width in pixels - * @param height The height in pixels + * Creates a frame of an image. The frame uses the image as it is (it isn't copied): changes to it are the frame's + * @param image The frame's pixels * @param options See {@link FrameOptions} */ - constructor(width: number, height: number, options?: FrameOptions); - constructor(width: number, height: number, options: FrameOptions = {}) { - checkOptions(options, 'Frame(width, height, { duration: 100 })'); + constructor(image: Image, options?: FrameOptions); + constructor(image: Image, options: FrameOptions = {}) { + if (!(image instanceof Image)) throw new TypeError('A frame takes an Image, like new Frame(image, { duration: 100 })'); + checkOptions(options, 'Frame(image, { duration: 100 })'); const { duration = 100, xOffset: x = 0, yOffset: y = 0, disposalMode: disposal = 'keep' } = options; - if (finite(duration, 'frame duration') < 0) - throw new RangeError('Invalid frame duration'); - finite(x, 'x offset'); - finite(y, 'y offset'); - - super(width, height); + this.__image__ = image; this.duration = duration; this.xOffset = x; this.yOffset = y; this.disposalMode = disposal; } + /** The frame's pixels. Changing the image changes the frame; the offsets stay as they are */ + get image(): Image { + return this.__image__; + } + + /** + * Replaces the frame's pixels + * @param image The new image (used as it is, not copied) + */ + set image(image: Image) { + if (!(image instanceof Image)) throw new TypeError('A frame\'s image has to be an Image'); + this.__image__ = image; + } + + /** The frame's width (its image's) */ + get width(): number { + return this.__image__.width; + } + + /** The frame's height (its image's) */ + get height(): number { + return this.__image__.height; + } + /** * The frame's duration in milliseconds (a finite number from 0, else it throws a RangeError). GIFs store * hundredths of a second, so this is rounded to 10 ms when encoding a GIF; note that browsers play frames shorter @@ -123,7 +145,7 @@ export class Frame extends Image { this.__y__ = finite(y, 'y offset'); } - /** The frame's disposal mode (`'any'`, `'keep'`, `'previous'` or `'background'`) */ + /** The frame's disposal mode, see {@link FrameDisposalModeName} */ get disposalMode(): FrameDisposalModeName { return DISPOSAL_MODES[this.__disposalMode__]; } @@ -146,100 +168,82 @@ export class Frame extends Image { } /** A short description for debugging, e.g. `Frame<64x64x100ms>` */ - override toString(): string { + toString(): string { return `Frame<${this.width}x${this.height}x${this.duration}ms>`; } - /** - * Converts an Image instance to a Frame, cloning it in the process - * @param image The image to create the frame from - * @param options See {@link FrameOptions} - */ - static from(image: Image, options?: FrameOptions): Frame; - static from(image: Image, options: FrameOptions = {}): Frame { - if (!(image instanceof Image)) - throw new TypeError('Invalid image passed'); - checkOptions(options, 'Frame.from(image, { duration: 100 })'); - - const frame = new Frame(image.width, image.height, options); - frame.bitmap.set(image.bitmap); - - return frame; + /** Clones the frame: its image (with its pixels and gain map), duration, offsets and disposal mode */ + clone(): Frame { + return new Frame(this.__image__.clone(), { duration: this.duration, xOffset: this.xOffset, yOffset: this.yOffset, disposalMode: this.disposalMode }); } /** - * Clones the frame, including its duration, offsets and disposal mode + * Resizes the frame, scaling its offsets along with it (as if the whole animation was resized by the same factor) + * @param width The new width + * @param height The new height + * @param options See {@link ResizeOptions} */ - override clone(): Frame { - return Frame.from(this, { duration: this.duration, xOffset: this.xOffset, yOffset: this.yOffset, disposalMode: this.disposalMode }); + resize(width: number, height: number, options?: ResizeOptions): this; + /** + * Resizes the frame, scaling its offsets along with it: to the given width or height (the other side keeps the + * aspect ratio), or to both + * @param options The size and how to resize, see {@link ResizeToOptions} + */ + resize(options: ResizeToOptions): this; + resize(widthOrOptions: number | ResizeToOptions, heightArgument?: number, optionsArgument?: ResizeOptions): this { + const originalWidth = this.width, originalHeight = this.height; + if (typeof widthOrOptions === 'number') this.__image__.resize(widthOrOptions, heightArgument as number, optionsArgument); + else { + resizeArguments(widthOrOptions, heightArgument, optionsArgument); + this.__image__.resize(widthOrOptions); + } + this.xOffset = Math.round(this.xOffset * this.width / originalWidth); + this.yOffset = Math.round(this.yOffset * this.height / originalHeight); + return this; } /** - * @internal - * @ignore + * Scales the frame by a factor, and its offsets along with it + * @param factor The factor + * @param options See {@link ResizeOptions} */ - protected override __room__(): { left: number, top: number } { - return { left: this.xOffset, top: this.yOffset }; + scale(factor: number, options?: ResizeOptions): this { + const originalWidth = this.width, originalHeight = this.height; + this.__image__.scale(factor, options); + this.xOffset = Math.round(this.xOffset * this.width / originalWidth); + this.yOffset = Math.round(this.yOffset * this.height / originalHeight); + return this; } /** - * @internal - * @ignore + * Crops the frame to a region of it, moving the offsets so the remaining pixels stay where they are in the + * animation. The region is clipped to the frame + * @param x The x offset in the frame (0-based) + * @param y The y offset in the frame (0-based) + * @param width The new width + * @param height The new height */ - protected override __grown__(left: number, top: number): void { - this.xOffset -= left; - this.yOffset -= top; + crop(x: number, y: number, width: number, height: number): this { + const left = Math.max(0, Math.floor(finite(x, 'x'))), top = Math.max(0, Math.floor(finite(y, 'y'))); + this.__image__.crop(x, y, width, height); + this.xOffset += left; + this.yOffset += top; + return this; } /** - * Removes borders of one color, moving the frame's offsets so the remaining pixels stay in place + * Removes borders of one color, moving the offsets so the remaining pixels stay where they are in the animation * @param options See {@link TrimOptions} */ - override trim(options?: TrimOptions): this; - override trim(options: TrimOptions = {}): this { + trim(options?: TrimOptions): this; + trim(options: TrimOptions = {}): this { checkOptions(options, 'trim({ tolerance: 0.1 })'); const { color, ...rest } = options; - const bounds = this.__trim_bounds__(color, rest); + const bounds = this.__image__.__trim_bounds__(color, rest); if (!bounds) return this; + this.__image__.crop(...bounds); this.xOffset += bounds[0]; this.yOffset += bounds[1]; - return this.__apply__(this.__crop__(...bounds)); - } - - /** - * Resizes the frame, scaling its offsets along with it. - * Pass {@link ResizeToOptions} with only a width or a height to keep the aspect ratio. - * @param width The new width - * @param height The new height - * @param options See {@link ResizeOptions} - */ - override resize(width: number, height: number, options?: ResizeOptions): this; - /** - * Resizes the frame, scaling its offsets along with it: to the given width or height (the other side keeps the - * aspect ratio), or to both - * @param options The size and how to resize, see {@link ResizeToOptions} - */ - override resize(options: ResizeToOptions): this; - override resize(widthOrOptions: number | ResizeToOptions, heightArgument?: number, optionsArgument?: ResizeOptions): this { - const [width, height, options, fit] = resizeArguments(widthOrOptions, heightArgument, optionsArgument); - const originalWidth = this.width; - const originalHeight = this.height; - - super.resize(...this.__resized_size__(width, height, fit), options); - - this.xOffset = Math.round(this.xOffset * this.width / originalWidth); - this.yOffset = Math.round(this.yOffset * this.height / originalHeight); - return this; } - - /** - * Scales the offsets like {@link Frame.resize} for a resize to the given size (that only produces a part of it) - * @internal - * @ignore - */ - protected override __rescaled__(width: number, height: number): void { - this.xOffset = Math.round(this.xOffset * width / this.width); - this.yOffset = Math.round(this.yOffset * height / this.height); - } } diff --git a/src/image.ts b/src/image.ts index 997e2acc..6458ce51 100644 --- a/src/image.ts +++ b/src/image.ts @@ -177,8 +177,8 @@ export interface TransformOptions { }; /** * Whether to make the image as large as the transformed one, which then fills it (a rotated image grows to fit its - * corners, a skewed one gets wider). Without it, the image keeps its size and the parts outside are lost. Frames - * only grow up and left as far as their offset allows. Default: `true` + * corners, a skewed one gets wider). Without it, the image keeps its size and the parts outside are lost. + * Default: `true` */ expand?: boolean; /** @@ -1007,7 +1007,6 @@ export class Image { // only the part the crop keeps is resampled (the same pixels as resizing the whole image, then cropping) if (resizeOpts.mode !== 'nearest-neighbor' && width >= 1 && height >= 1) { const region = { x: ~~((resizedWidth - width) / 2), y: ~~((resizedHeight - height) / 2), width, height }; - this.__rescaled__?.(resizedWidth, resizedHeight); const fx = resizedWidth / this.width, fy = resizedHeight / this.height; this.__gain_map__?.__transform__(this.width, this.height, width, height, (X, Y) => [(X + region.x) / fx, (Y + region.y) / fy], Math.min(fx, fy)); return this.__apply__(this.__resize__(resizedWidth, resizedHeight, resizeOpts.mode, resizeOpts.fast, region)); @@ -1140,8 +1139,7 @@ export class Image { /** * Removes borders of one color, e.g. the empty space around rendered text or around a logo on a solid background. - * Frames keep their position: their offsets move by the trimmed amount. An image that only has the color stays - * unchanged + * An image that only has the color stays unchanged ({@link Frame.trim} also moves the frame's offsets) * @param options See {@link TrimOptions} */ trim(options?: TrimOptions): this; @@ -1159,7 +1157,7 @@ export class Image { * @internal * @ignore */ - protected __trim_bounds__(color: Color | undefined, { tolerance = 0 }: TrimOptions): [number, number, number, number] | null { + __trim_bounds__(color: Color | undefined, { tolerance = 0 }: TrimOptions): [number, number, number, number] | null { if (!(tolerance >= 0 && tolerance <= 1)) throw new RangeError('Invalid tolerance, it has to be between 0 and 1'); const bitmap = this.bitmap; @@ -1685,7 +1683,7 @@ export class Image { * 0x8bac0fff, 0x9bbc0fff] })` for a Game Boy look. Transparent pixels (alpha 0) keep their color and don't count * towards the palette; the other pixels keep their alpha and only their RGB is reduced. The result is deterministic. * With a gain map (an HDR image), the HDR highlights are dropped, like {@link Image.invert} does: the map is neutral - * afterwards. Frames quantize their own pixels + * afterwards * @param options See {@link QuantizeOptions} */ quantize(options: QuantizeOptions = {}): this { @@ -1810,16 +1808,6 @@ export class Image { outHeight = Math.max(1, Math.round(maxY - minY)); left = minX - (outWidth - (maxX - minX)) / 2; top = minY - (outHeight - (maxY - minY)) / 2; - // frames only grow as far up and left as their offset allows - const room = this.__room__(); - if (left < -room.left) { - left = -room.left; - outWidth = Math.max(1, Math.round(maxX - left)); - } - if (top < -room.top) { - top = -room.top; - outHeight = Math.max(1, Math.round(maxY - top)); - } if (outWidth * outHeight > MAX_PIXELS) throw new RangeError(`The transformed image would have ${outWidth}x${outHeight} pixels, more than the ${MAX_PIXELS} allowed`); } @@ -1830,17 +1818,13 @@ export class Image { const x = X + left, y = Y + top; return [inverse[0] * x + inverse[2] * y + inverse[4], inverse[1] * x + inverse[3] * y + inverse[5]]; }, scale); - this.__apply__(out); - // (a frame's offsets are whole pixels) - if (left || top) this.__grown__(-Math.round(left), -Math.round(top)); - return this; + return this.__apply__(out); } /** * Adds a shadow behind the image, shaped by its alpha: a drop shadow with an offset, or a glow without one * (`image.dropShadow({ blur: 8, color: 0xff2fb9ff })`, with `spread` to make it stronger). The image grows to fit - * the shadow, unless `expand` is `false`. Frames stay where they are (their offsets move with the growth), they only - * grow up and left as far as their offset allows + * the shadow, unless `expand` is `false` * @param options See {@link ShadowOptions} */ dropShadow(options: ShadowOptions = {}): this { @@ -1877,10 +1861,9 @@ export class Image { } } - const room = this.__room__(); return this.__under__(layer, x - margin, y - margin, expand ? { - left: Math.min(room.left, Math.max(0, Math.ceil(reach - x))), - top: Math.min(room.top, Math.max(0, Math.ceil(reach - y))), + left: Math.max(0, Math.ceil(reach - x)), + top: Math.max(0, Math.ceil(reach - y)), right: Math.max(0, Math.ceil(reach + x)), bottom: Math.max(0, Math.ceil(reach + y)) } : { left: 0, top: 0, right: 0, bottom: 0 }); @@ -1888,8 +1871,7 @@ export class Image { /** * Draws an outline around the image's shape (its alpha): the sticker effect for cut-out images, or an outline for - * text. The image grows to fit it, unless `expand` is `false`. Frames stay where they are (their offsets move with - * the growth), they only grow up and left as far as their offset allows + * text. The image grows to fit it, unless `expand` is `false` * @param options See {@link OutlineOptions} */ outline(options: OutlineOptions = {}): this { @@ -1913,9 +1895,9 @@ export class Image { bitmap[i * 4 + 3] = stroke[i] * (1 - coverage[i] / 255) * a; } - const room = this.__room__(), grow = Math.ceil(width); + const grow = Math.ceil(width); return this.__under__(layer, -margin, -margin, expand - ? { left: Math.min(room.left, grow), top: Math.min(room.top, grow), right: grow, bottom: grow } + ? { left: grow, top: grow, right: grow, bottom: grow } : { left: 0, top: 0, right: 0, bottom: 0 }); } @@ -1952,29 +1934,9 @@ export class Image { } map?.__transform__(this.width, this.height, width, height, (X, Y) => [X - left, Y - top]); this.__apply__(result); - if (left || top) this.__grown__(left, top); return this; } - /** - * How far the image may grow up and left - * @internal - * @ignore - */ - protected __room__(): { left: number, top: number } { - return { left: Infinity, top: Infinity }; - } - - /** - * Called when the image has grown up and left - * @internal - * @ignore - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected __grown__(_left: number, _top: number): void { - // (frames move their offsets) - } - /** * Sharpens the image with an unsharp mask: the difference to a blurred copy is amplified * @param options See {@link SharpenOptions} @@ -2295,12 +2257,6 @@ export class Image { } } - /** - * For a resize to the given size that only produces a part of it (frames scale their offsets) - * @internal - * @ignore - */ - protected __rescaled__?(width: number, height: number): void; /** * @internal diff --git a/src/index.ts b/src/index.ts index 19c8da53..e91742fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,5 @@ import { BinaryData } from './util/mem.ts'; import { Image, DecodeOptions } from './image.ts'; -import { Frame } from './frame.ts'; import { Animation } from './animation.ts'; import { checkOptions } from './util/options.ts'; import * as png from './codecs/png.ts'; @@ -45,9 +44,5 @@ export async function decode(input: BinaryData, options: DecodeOptions = {}): Pr const animation = await Animation.decode(input, options); if (animation.length !== 1) return animation; // the only frame, as an image: its pixels and gain map - const frame: Frame = animation.frames[0]; - const image = new Image(frame.width, frame.height); - image.bitmap = frame.bitmap; - image.gainMap = frame.gainMap; - return image; + return animation.frames[0].image; } diff --git a/tests/animation-class.js b/tests/animation-class.js index ec8edac4..91f05433 100644 --- a/tests/animation-class.js +++ b/tests/animation-class.js @@ -12,9 +12,9 @@ const throwsA = (fn, type, label) => { return error; }; -const a = new Frame(4, 4, {duration: 10}).fill(0xff0000ff); -const b = new Frame(2, 2, {duration: 20, xOffset: 1, yOffset: 1}).fill(0x00ff00ff); -const c = new Frame(3, 3, {duration: 30}).fill(0x0000ffff); +const a = new Frame(new Image(4, 4).fill(0xff0000ff), {duration: 10}); +const b = new Frame(new Image(2, 2).fill(0x00ff00ff), {duration: 20, xOffset: 1, yOffset: 1}); +const c = new Frame(new Image(3, 3).fill(0x0000ffff), {duration: 30}); // an animation holds its frames: it is not an array const animation = new Animation([a, b]); @@ -79,4 +79,4 @@ const sized = new Animation([a, b]); if (sized.width !== 4 || sized.height !== 4 || sized.duration !== 30) panic(`size ${sized.width}x${sized.height}, ${sized.duration} ms`); if (sized.toString() !== 'Animation<4x4x30ms>') panic(`toString: ${sized.toString()}`); const first = new Image(2, 2); -if (new Animation([Frame.from(first, {duration: 5})]).frames[0].duration !== 5) panic('Frame.from frames'); +if (new Animation([new Frame(first.clone(), {duration: 5})]).frames[0].duration !== 5) panic('Frame from an image'); diff --git a/tests/animation-encoder.js b/tests/animation-encoder.js index 3a66ff9c..ea482254 100644 --- a/tests/animation-encoder.js +++ b/tests/animation-encoder.js @@ -13,12 +13,12 @@ const metadata = {creationTime: 0, software: ''}; (async () => { const frames = []; for (let i = 0; i < 6; i++) { - const f = new Frame(20 + i, 15, {duration: 50 + i * 10, xOffset: i * 3, yOffset: i, disposalMode: ['keep', 'previous', 'background', 'keep', 'any', 'background'][i]}); - f.fill((x, y) => Colors.fromRGBA((x * 30 + i * 40) & 255, y * 15, i * 40, (x + y + i) % 5 ? 255 : 100)); + const f = new Frame(new Image(20 + i, 15), {duration: 50 + i * 10, xOffset: i * 3, yOffset: i, disposalMode: ['keep', 'previous', 'background', 'keep', 'any', 'background'][i]}); + f.image.fill((x, y) => Colors.fromRGBA((x * 30 + i * 40) & 255, y * 15, i * 40, (x + y + i) % 5 ? 255 : 100)); frames.push(f); } // offsets, every disposal, translucent pixels; a decoded GIF - const synthetic = new Animation([new Frame(40, 30, {duration: 20}).fill(0x203040ff), ...frames], {loopCount: 2}); + const synthetic = new Animation([new Frame(new Image(40, 30).fill(0x203040ff), {duration: 20}), ...frames], {loopCount: 2}); const decoded = await Animation.decode(await fs.readFile('./tests/targets/gif.gif')); for (const [name, animation] of [['synthetic', synthetic], ['gif.gif', decoded]]) @@ -39,7 +39,7 @@ const metadata = {creationTime: 0, software: ''}; const a = await Animation.decode(file), b = await Animation.decode(expected); const differs = (f, g) => options.quality === undefined ? f.bitmap.some((v, k) => Math.abs(v - g.bitmap[k]) > 1) : f.bitmap.reduce((sum, v, k) => sum + Math.abs(v - g.bitmap[k]), 0) / f.bitmap.length > 4; - if (a.length !== b.length || a.frames.some((f, i) => f.duration !== b.frames[i].duration || differs(f, b.frames[i]))) + if (a.length !== b.length || a.frames.some((f, i) => f.duration !== b.frames[i].duration || differs(f.image, b.frames[i].image))) panic(`${name} ${format} ${JSON.stringify(options)}: doesn't show the same animation`); } @@ -50,10 +50,10 @@ const metadata = {creationTime: 0, software: ''}; const added = encoder.add(image, {duration: 70}); image.fill(0x00ff00ff); await added; - await encoder.add(new Frame(4, 4, {duration: 10}).fill(0x0000ffff), {duration: 30, xOffset: 2, yOffset: 2}); + await encoder.add(new Frame(new Image(4, 4).fill(0x0000ffff), {duration: 10}), {duration: 30, xOffset: 2, yOffset: 2}); const file = await encoder.finish(); - const expected = await new Animation([Frame.from(new Image(8, 8).fill(0xff0000ff), {duration: 70}), - new Frame(4, 4, {duration: 30, xOffset: 2, yOffset: 2}).fill(0x0000ffff)]).encodeAPNG({metadata}); + const expected = await new Animation([new Frame(new Image(8, 8).fill(0xff0000ff), {duration: 70}), + new Frame(new Image(4, 4).fill(0x0000ffff), {duration: 30, xOffset: 2, yOffset: 2})]).encodeAPNG({metadata}); if (!equal(file, expected)) panic('images and options: not the same file'); } diff --git a/tests/animation-optimize.js b/tests/animation-optimize.js index caa0b254..62132534 100644 --- a/tests/animation-optimize.js +++ b/tests/animation-optimize.js @@ -11,7 +11,7 @@ async function timeline(animation) { const decoded = animation instanceof Animation ? animation : await Animation.decode(animation); const result = []; for (const frame of decoded) { - const pixels = Buffer.from(frame.bitmap); + const pixels = Buffer.from(frame.image.bitmap); for (let i = 0; i < pixels.length; i += 4) if (pixels[i + 3] === 0) pixels.fill(0, i, i + 4); const last = result[result.length - 1]; if (last && last.pixels.equals(pixels)) last.duration += frame.duration; @@ -26,9 +26,9 @@ async function composite(animation) { const {width, height} = animation; const canvases = png.compositeAnimation({width, height, frames: animation.__apngFrames__()}); return new Animation(canvases.map(({pixels, duration}) => { - const frame = new Frame(width, height, {duration}); - frame.bitmap.set(pixels); - return frame; + const image = new Image(width, height); + image.bitmap.set(pixels); + return new Frame(image, {duration}); })); } @@ -71,34 +71,34 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { (async () => { // a sticker: an antialiased disc moving over a transparent background (pixels become transparent again) await check('moving sticker', new Animation(Array.from({length: 8}, (_, i) => - Frame.from(new Image(48, 32).composite(disc(16, 0xff8800ff), {x: i * 4, y: 4 + (i & 1)}), {duration: 60})))); + new Frame(new Image(48, 32).composite(disc(16, 0xff8800ff), {x: i * 4, y: 4 + (i & 1)}), {duration: 60})))); // fading out: pixels get more transparent await check('fade out', new Animation(Array.from({length: 6}, (_, i) => - Frame.from(new Image(20, 20).composite(new Image(12, 12).fill(0x3366ff00 | 255 - i * 50), {x: 4, y: 4}), {duration: 100})))); + new Frame(new Image(20, 20).composite(new Image(12, 12).fill(0x3366ff00 | 255 - i * 50), {x: 4, y: 4}), {duration: 100})))); // frames with offsets and disposal modes, odd offsets (WebP stores them halved) await check('offsets and disposal', new Animation([ - Frame.from(new Image(30, 30).fill(0x202020ff), {duration: 100}), - new Frame(5, 7, {duration: 100, xOffset: 3, yOffset: 9, disposalMode: 'previous'}).fill(0xff0000ff), - new Frame(9, 3, {duration: 100, xOffset: 11, yOffset: 1, disposalMode: 'background'}).fill(0x00ff0080), - new Frame(4, 4, {duration: 100, xOffset: 21, yOffset: 21, disposalMode: 'keep'}).fill(0x0000ffff), - new Frame(30, 30, {duration: 100, xOffset: 0, yOffset: 0, disposalMode: 'background'}).fill(0), - new Frame(1, 1, {duration: 100, xOffset: 29, yOffset: 29}).fill(0xffffffff) + new Frame(new Image(30, 30).fill(0x202020ff), {duration: 100}), + new Frame(new Image(5, 7).fill(0xff0000ff), {duration: 100, xOffset: 3, yOffset: 9, disposalMode: 'previous'}), + new Frame(new Image(9, 3).fill(0x00ff0080), {duration: 100, xOffset: 11, yOffset: 1, disposalMode: 'background'}), + new Frame(new Image(4, 4).fill(0x0000ffff), {duration: 100, xOffset: 21, yOffset: 21, disposalMode: 'keep'}), + new Frame(new Image(30, 30).fill(0), {duration: 100, xOffset: 0, yOffset: 0, disposalMode: 'background'}), + new Frame(new Image(1, 1).fill(0xffffffff), {duration: 100, xOffset: 29, yOffset: 29}) ])); // a frame that only clears: GIF can't blend pixels to transparent, the frame before clears its area instead await check('cleared frame', new Animation([ - Frame.from(new Image(12, 12).composite(new Image(6, 6).fill(0xff0000ff), {x: 2, y: 2}), {duration: 100, xOffset: 0, yOffset: 0, disposalMode: 'background'}), - new Frame(12, 12, {duration: 100}), - Frame.from(new Image(12, 12).composite(new Image(6, 6).fill(0x00ff00ff), {x: 4, y: 4}), {duration: 100}) + new Frame(new Image(12, 12).composite(new Image(6, 6).fill(0xff0000ff), {x: 2, y: 2}), {duration: 100, xOffset: 0, yOffset: 0, disposalMode: 'background'}), + new Frame(new Image(12, 12), {duration: 100}), + new Frame(new Image(12, 12).composite(new Image(6, 6).fill(0x00ff00ff), {x: 4, y: 4}), {duration: 100}) ])); // an empty first frame, and identical frames, which become one with their durations added up (the empty last // frame doesn't clear the canvas, so it shows the same too) { - const dot = Frame.from(new Image(10, 10).setPixelAt(5, 5, 0xffffffff), {duration: 100}); - const animation = new Animation([new Frame(10, 10, {duration: 40}), dot, dot.clone(), Frame.from(dot, {duration: 25}), new Frame(10, 10, {duration: 30})]); + const dot = new Frame(new Image(10, 10).setPixelAt(5, 5, 0xffffffff), {duration: 100}); + const animation = new Animation([new Frame(new Image(10, 10), {duration: 40}), dot, dot.clone(), new Frame(dot.image.clone(), {duration: 25}), new Frame(new Image(10, 10), {duration: 30})]); await check('empty and identical frames', animation); const decoded = await Animation.decode(await animation.encodeAPNG()); if (decoded.length !== 2 || decoded.frames[0].duration !== 40 || decoded.frames[1].duration !== 255) panic(`identical frames: ${decoded.length} frames, durations ${Array.from(decoded.frames, f => f.duration)}`); @@ -108,7 +108,7 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { // frame: they're filled in again { const background = new Image(40, 20).fill((x, y) => Colors.fromRGBA(y * 6, 100, 200 - y * 4, 255)); - const animation = new Animation(Array.from({length: 6}, (_, i) => Frame.from(background.clone() + const animation = new Animation(Array.from({length: 6}, (_, i) => new Frame(background.clone() .composite(new Image(6, 6).fill(Colors.fromRGBA(255, i * 40, 0, 255)), {x: 1, y: 1}) .composite(new Image(6, 6).fill(Colors.fromRGBA(0, 255, i * 40, 255)), {x: 32, y: 13}), {duration: 100}))); await check('opaque animation', animation); @@ -129,22 +129,22 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { for (let j = 0, k = 0; j < 24; j++) for (let i = 0; i < 24; i++) blocks.push(ring(i, j) ? (k++ + shift) & 255 : (i * 7 + j * 13) & 255); return new Image(96, 96).fill((x, y) => color(blocks[(y >> 2) * 24 + (x >> 2)])); }; - const animation = new Animation([Frame.from(grid(0), {duration: 100}), Frame.from(grid(1), {duration: 100})]); + const animation = new Animation([new Frame(grid(0), {duration: 100}), new Frame(grid(1), {duration: 100})]); const decoded = await Animation.decode(await animation.encodeGIF()); decoded.frames.forEach((frame, i) => { - if (frame.bitmap.some((v, k) => v !== animation.frames[i].bitmap[k])) panic(`palette: GIF frame ${i} lost colors`); + if (frame.image.bitmap.some((v, k) => v !== animation.frames[i].image.bitmap[k])) panic(`palette: GIF frame ${i} lost colors`); }); } // optimize: false stores the frames as they are: same animation, but no frames merged or cut { - const dot = Frame.from(new Image(10, 10).setPixelAt(5, 5, 0xffffffff), {duration: 100}); - const animation = new Animation([new Frame(10, 10, {duration: 40}), dot, dot.clone(), new Frame(4, 4, {duration: 30, xOffset: 3, yOffset: 1}).fill(0xff0000ff)]); + const dot = new Frame(new Image(10, 10).setPixelAt(5, 5, 0xffffffff), {duration: 100}); + const animation = new Animation([new Frame(new Image(10, 10), {duration: 40}), dot, dot.clone(), new Frame(new Image(4, 4).fill(0xff0000ff), {duration: 30, xOffset: 3, yOffset: 1})]); for (const format of ['GIF', 'APNG', 'WebP']) { const plain = await Animation.decode(await animation[`encode${format}`]({optimize: false})); if (plain.length !== 4) panic(`optimize false: ${format} has ${plain.length} frames instead of 4`); const expected = await composite(animation); - if (format === 'APNG' && plain.frames.some((f, i) => f.bitmap.some((v, k) => v !== expected.frames[i].bitmap[k]))) panic('optimize false: APNG frames differ'); + if (format === 'APNG' && plain.frames.some((f, i) => f.image.bitmap.some((v, k) => v !== expected.frames[i].image.bitmap[k]))) panic('optimize false: APNG frames differ'); } await check('optimize false', animation); } @@ -153,12 +153,12 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { { const background = new Image(160, 120).fill((x, y) => Colors.fromRGBA(x * 7 ^ y * 13, x * y, y * 5, 255)); const animation = new Animation(Array.from({length: 20}, (_, i) => - Frame.from(background.clone().composite(new Image(8, 8).fill(0xffffffff), {x: 10 + i * 6, y: 50}), {duration: 50}))); + new Frame(background.clone().composite(new Image(8, 8).fill(0xffffffff), {x: 10 + i * 6, y: 50}), {duration: 50}))); await check('screen recording', animation); const still = (await background.encodePNG()).length; for (const format of ['APNG', 'WebP', 'GIF']) { const size = (await animation[`encode${format}`]()).length; - if (size > 3 * (format === 'GIF' ? (await new Animation([Frame.from(background)]).encodeGIF()).length : still)) + if (size > 3 * (format === 'GIF' ? (await new Animation([new Frame(background.clone())]).encodeGIF()).length : still)) panic(`screen recording: ${format} is ${size} bytes, the changes aren't stored alone`); } } @@ -175,7 +175,7 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { }); const animation = new Animation(Array.from({length: 8}, (_, i) => { const layer = new Image(100, 60).fill(x => x < 80 ? Colors.fromRGBA(i % 4, 180 + i * 7 % 5, 250 + i * 3 % 4, 150) : 0); - return Frame.from(map.clone().composite(layer).composite(new Image(12, 12).fill(0xff0000ff), {x: 5 + i * 10, y: 20}) + return new Frame(map.clone().composite(layer).composite(new Image(12, 12).fill(0xff0000ff), {x: 5 + i * 10, y: 20}) .composite(new Image(6, 6).fill(Colors.fromRGBA(0, i * 30, 0, 255)), {x: 90, y: 50}), {duration: 50}); })); @@ -197,7 +197,7 @@ const disc = (size, color) => new Image(size, size).fill((x, y) => { const png = await import('../dist/codecs/png.js'); const shown = png.compositeAnimation({width: 100, height: 60, frames: frames.map(f => ({...f, dispose: f.dispose ? 1 : 0}))}); shown.forEach(({pixels}, i) => { - const expected = animation.frames[i].bitmap; + const expected = animation.frames[i].image.bitmap; for (let k = 0; k < pixels.length; k++) if (Math.abs(pixels[k] - expected[k]) > tolerance) panic(`lossy WebP: frame ${i} differs by ${Math.abs(pixels[k] - expected[k])} at pixel ${k >> 2}`); }); diff --git a/tests/animation.js b/tests/animation.js index 8f1f3020..cc0fdd7a 100644 --- a/tests/animation.js +++ b/tests/animation.js @@ -11,7 +11,7 @@ function expected(animation) { let canvas = new Image(width, height); return Array.from(animation, frame => { const previous = canvas.clone(); - canvas.composite(frame, {x: frame.xOffset, y: frame.yOffset}); + canvas.composite(frame.image, {x: frame.xOffset, y: frame.yOffset}); const shown = canvas.clone(); if (frame.disposalMode === 'background') for (let y = Math.max(0, frame.yOffset); y < Math.min(height, frame.yOffset + frame.height); y++) @@ -26,10 +26,10 @@ const square = (color, w, h) => new Image(w, h).fill((x, y) => (x + y) % 5 === 0 (async () => { for (const variant of [0, 1]) { const animation = new Animation([ - Frame.from(new Image(40, 30).fill((x, y) => Colors.fromRGBA(x * 6, y * 8, 128, 255)), {duration: 100}), - Frame.from(square(0xff2040ff, 10, 8), {duration: 50, xOffset: variant ? 3 : 4, yOffset: variant ? 5 : 6, disposalMode: 'background'}), - Frame.from(square(0x20ff40ff, 12, 10), {duration: 70, xOffset: 10, yOffset: 10, disposalMode: variant ? 'previous' : 'keep'}), - Frame.from(square(0xc8374fff, 8, 8), {duration: 30, xOffset: 20, yOffset: 2, disposalMode: 'keep'}) + new Frame(new Image(40, 30).fill((x, y) => Colors.fromRGBA(x * 6, y * 8, 128, 255)), {duration: 100}), + new Frame(square(0xff2040ff, 10, 8), {duration: 50, xOffset: variant ? 3 : 4, yOffset: variant ? 5 : 6, disposalMode: 'background'}), + new Frame(square(0x20ff40ff, 12, 10), {duration: 70, xOffset: 10, yOffset: 10, disposalMode: variant ? 'previous' : 'keep'}), + new Frame(square(0xc8374fff, 8, 8), {duration: 30, xOffset: 20, yOffset: 2, disposalMode: 'keep'}) ], {loopCount: 2}); const reference = expected(animation); @@ -39,13 +39,13 @@ const square = (color, w, h) => new Image(w, h).fill((x, y) => (x + y) % 5 === 0 if (back.length !== 4 || back.loopCount !== 2) panic(`${format}: wrong frame or loop count`); if (back.frames.map(f => f.duration).join() !== '100,50,70,30') panic(`${format}: wrong durations`); back.frames.forEach((frame, i) => { - if (!Buffer.from(frame.bitmap).equals(Buffer.from(reference[i].bitmap))) panic(`${format} variant ${variant}: frame ${i} differs`); + if (!Buffer.from(frame.image.bitmap).equals(Buffer.from(reference[i].bitmap))) panic(`${format} variant ${variant}: frame ${i} differs`); }); } } // a plain PNG's default image is what Image.decode returns for an APNG - const animation = new Animation([Frame.from(new Image(8, 8).fill(0xff0000ff), {duration: 100}), Frame.from(new Image(8, 8).fill(0x00ff00ff), {duration: 100})]); + const animation = new Animation([new Frame(new Image(8, 8).fill(0xff0000ff), {duration: 100}), new Frame(new Image(8, 8).fill(0x00ff00ff), {duration: 100})]); const apng = await animation.encodeAPNG(); const still = await Image.decode(apng); if (still.getPixelAt(0, 0) !== 0xff0000ff) panic('APNG: the default image should be the first frame'); @@ -53,15 +53,15 @@ const square = (color, w, h) => new Image(w, h).fill((x, y) => (x + y) % 5 === 0 // still images decode to a single frame; GIFs stay GIFs const single = await Animation.decode(await still.encodePNG()); if (single.length !== 1 || single.width !== 8) panic('Animation.decode should accept still images'); - const gif = await Animation.decode(await new Animation([Frame.from(still)]).encodeGIF()); + const gif = await Animation.decode(await new Animation([new Frame(still.clone())]).encodeGIF()); if (!(gif instanceof Animation) || gif.length !== 1) panic('Animation.decode should decode GIFs'); if (!(new Animation(gif.frames.slice(0, 1), {loopCount: gif.loopCount}) instanceof Animation)) panic('a new animation of some frames'); // frames: the animation's frames, changing them changes the animation { - const animation = new Animation([Frame.from(still)]); + const animation = new Animation([new Frame(still.clone())]); if (animation.frames.length !== 1) panic('frames should hold the frames'); - animation.frames.push(Frame.from(still, {duration: 50})); + animation.frames.push(new Frame(still.clone(), {duration: 50})); if (animation.length !== 2 || animation.duration !== 150) panic('frames.push should add a frame'); if (!Array.isArray(animation.frames.map(f => f.duration))) panic('frames.map should return an array'); animation.frames = animation.frames.filter(f => f.duration === 50); @@ -74,7 +74,7 @@ const square = (color, w, h) => new Image(w, h).fill((x, y) => (x + y) % 5 === 0 // loop counts: Infinity forever, 0 once, n repeats for (const loopCount of [Infinity, 0, 1, 5]) { // (different frames: identical ones are merged, and a single frame has no loop count) - const two = new Animation([Frame.from(still), Frame.from(still.clone().invert())], {loopCount}); + const two = new Animation([new Frame(still.clone()), new Frame(still.clone().invert())], {loopCount}); for (const format of ['APNG', 'WebP', 'GIF']) if ((await Animation.decode(await two[`encode${format}`]())).loopCount !== loopCount) panic(`${format}: loop count ${loopCount} not kept`); } diff --git a/tests/api-shapes.js b/tests/api-shapes.js index 1998650b..00c05c65 100644 --- a/tests/api-shapes.js +++ b/tests/api-shapes.js @@ -27,14 +27,14 @@ const throws = (call, type, message) => { assert(filled.width === 30 && filled.height === 30, 'resize: fill is the default'); throws(() => photo().resize({width: 30, height: 30, fit: 'contain'}), TypeError, 'resize: unknown fit'); throws(() => photo().contain(30, 30, {background: NaN}), RangeError, 'contain: NaN background'); - const frame = Frame.from(photo(), {xOffset: 10, yOffset: 4}); + const frame = new Frame(photo(), {xOffset: 10, yOffset: 4}); frame.resize({width: 20, height: 20, fit: 'inside'}); assert(frame.width === 20 && frame.height === 10 && frame.xOffset === 5 && frame.yOffset === 2, `Frame.resize fit inside: ${frame} ${frame.xOffset},${frame.yOffset}`); } // Frame duration and offsets are checked when they are set, and keep their value when the check fails { - const frame = new Frame(4, 4, {duration: 50, xOffset: 1, yOffset: 2}); + const frame = new Frame(new Image(4, 4), {duration: 50, xOffset: 1, yOffset: 2}); throws(() => { frame.duration = -1; }, RangeError, 'frame.duration = -1'); throws(() => { frame.duration = NaN; }, RangeError, 'frame.duration = NaN'); throws(() => { frame.xOffset = Infinity; }, RangeError, 'frame.xOffset = Infinity'); @@ -48,7 +48,7 @@ const throws = (call, type, message) => { // an animation's canvas: its own size once set, frames outside it are clipped when encoding { - const red = Frame.from(new Image(10, 10).fill(0xff0000ff), {xOffset: 5, yOffset: 5}); + const red = new Frame(new Image(10, 10).fill(0xff0000ff), {xOffset: 5, yOffset: 5}); const auto = new Animation([red.clone()]); assert(auto.width === 15 && auto.height === 15, `auto canvas: ${auto}`); const sized = new Animation([red.clone()], {width: 30, height: 20}); @@ -60,13 +60,13 @@ const throws = (call, type, message) => { assert(decoded.width === 30 && decoded.height === 20, `${encode}: the canvas should be kept, got ${decoded}`); } // a frame partly outside: only the part inside is encoded - const over = new Animation([Frame.from(new Image(10, 10).fill(0x00ff00ff), {xOffset: 25, yOffset: 15}), Frame.from(new Image(2, 2).fill(0xff0000ff), {xOffset: 40, yOffset: 40})], {width: 30, height: 20}); + const over = new Animation([new Frame(new Image(10, 10).fill(0x00ff00ff), {xOffset: 25, yOffset: 15}), new Frame(new Image(2, 2).fill(0xff0000ff), {xOffset: 40, yOffset: 40})], {width: 30, height: 20}); const back = await Animation.decode(await over.encodeAPNG({optimize: false})); assert(back.width === 30 && back.height === 20 && back.length === 2, `clipped frames: ${back} ${back.length}`); // (optimized, the empty second frame merges into the first) const merged = await Animation.decode(await over.encodeAPNG()); assert(merged.length === 1 && merged.duration === 200, `clipped frames, optimized: ${merged} ${merged.length}`); - assert(back.frames[0].getPixelAt(29, 19) === 0x00ff00ff && back.frames[0].getPixelAt(24, 19) === 0, 'clipped frames: the part inside the canvas'); + assert(back.frames[0].image.getPixelAt(29, 19) === 0x00ff00ff && back.frames[0].image.getPixelAt(24, 19) === 0, 'clipped frames: the part inside the canvas'); assert(over.frames[0].width === 10, 'encoding should not change the frames'); // decoded animations keep the file's canvas, even when the frames would say otherwise const decoded = await Animation.decode(await sized.encodeGIF()); @@ -77,13 +77,13 @@ const throws = (call, type, message) => { // Animation.clone, crop, contain, cover and resize({ fit: 'inside' }) { const make = () => new Animation([ - Frame.from(new Image(20, 10).fill(0xff0000ff), {duration: 50}), - Frame.from(new Image(10, 10).fill(0x0000ffff), {xOffset: 10, duration: 70}) + new Frame(new Image(20, 10).fill(0xff0000ff), {duration: 50}), + new Frame(new Image(10, 10).fill(0x0000ffff), {xOffset: 10, duration: 70}) ], {width: 20, height: 10, loopCount: 3}); const original = make(), copy = original.clone(); - copy.frames[0].fill(0x00ff00ff); + copy.frames[0].image.fill(0x00ff00ff); copy.frames[1].xOffset = 0; - assert(original.frames[0].getPixelAt(0, 0) === 0xff0000ff && original.frames[1].xOffset === 10, 'clone: independent frames'); + assert(original.frames[0].image.getPixelAt(0, 0) === 0xff0000ff && original.frames[1].xOffset === 10, 'clone: independent frames'); assert(copy.loopCount === 3 && copy.width === 20 && copy.height === 10 && copy.frames[1].duration === 70, 'clone: loop count, canvas, durations'); const cropped = make().crop(5, 2, 10, 6); @@ -94,8 +94,8 @@ const throws = (call, type, message) => { const contained = make().contain(40, 40); assert(contained.width === 40 && contained.height === 40 && contained.frames[0].width === 40 && contained.frames[0].yOffset === 10, `contain: ${contained} ${contained.frames[0]} ${contained.frames[0].yOffset}`); const withBackground = make().contain(40, 40, {background: 0xffffffff}); - assert(withBackground.frames.every(f => f.width === 40 && f.height === 40 && f.getPixelAt(0, 0) === 0xffffffff), 'contain: background frames'); - assert(withBackground.frames[1].getPixelAt(30, 20) === 0x0000ffff && withBackground.frames[1].getPixelAt(5, 20) === 0xff0000ff, 'contain: background frames show the composited animation'); + assert(withBackground.frames.every(f => f.width === 40 && f.height === 40 && f.image.getPixelAt(0, 0) === 0xffffffff), 'contain: background frames'); + assert(withBackground.frames[1].image.getPixelAt(30, 20) === 0x0000ffff && withBackground.frames[1].image.getPixelAt(5, 20) === 0xff0000ff, 'contain: background frames show the composited animation'); assert(withBackground.frames.map(f => f.duration).join() === '50,70', 'contain: durations kept'); const covered = make().cover(10, 10); @@ -170,9 +170,9 @@ const throws = (call, type, message) => { const none = await image.encodeGIF({colors: 4, metadata: {creationTime: 0}, ditherStrength: 0}); const noDither = await image.encodeGIF({colors: 4, metadata: {creationTime: 0}, dither: 'none'}); assert(Buffer.from(none).equals(Buffer.from(noDither)) || none.length > 0, 'encodeGIF: strength 0'); - const animation = new Animation([Frame.from(image), Frame.from(image.clone().flip('horizontal'))]); + const animation = new Animation([new Frame(image.clone()), new Frame(image.clone().flip('horizontal'))]); const decoded = await Animation.decode(await animation.encodeGIF({colors: 16, dither: 'atkinson'})); - assert(decoded.length === 2 && distinct(decoded.frames[0]) <= 16, 'Animation.encodeGIF with palette options'); + assert(decoded.length === 2 && distinct(decoded.frames[0].image) <= 16, 'Animation.encodeGIF with palette options'); let error = null; try { await image.encodeGIF({colors: 300}); } catch (e) { error = e; } assert(error instanceof RangeError, 'encodeGIF colors 300'); @@ -203,6 +203,40 @@ const throws = (call, type, message) => { assert(back.gainMap && back.gainMap.max[0] >= 2 && (back.getPixelAt(28, 8) >>> 8) < 0x101010, 'JPEG: a transparent image with a gain map'); } + // Frame holds an Image: the frame's own methods move the offsets, the image's leave them + { + const image = new Image(20, 10).fill((x, y) => Colors.fromRGBA(x * 12, y * 25, 0, 255)); + const frame = new Frame(image, {duration: 40, xOffset: 10, yOffset: 6}); + assert(frame.image === image && frame.width === 20 && frame.height === 10 && !(frame instanceof Image), 'Frame: holds the image as it is'); + assert(String(frame) === 'Frame<20x10x40ms>', 'Frame: toString'); + frame.image.blur(1); + assert(frame.xOffset === 10 && frame.yOffset === 6, 'Frame: image changes keep the offsets'); + frame.resize({width: 40}); + assert(frame.width === 40 && frame.height === 20 && frame.xOffset === 20 && frame.yOffset === 12, `Frame.resize scales the offsets: ${frame.xOffset},${frame.yOffset}`); + frame.scale(0.5); + assert(frame.width === 20 && frame.xOffset === 10 && frame.yOffset === 6, 'Frame.scale scales the offsets'); + const pixel = frame.image.getPixelAt(5, 4); + frame.crop(3, 2, 10, 5); + assert(frame.width === 10 && frame.height === 5 && frame.xOffset === 13 && frame.yOffset === 8 && frame.image.getPixelAt(2, 2) === pixel, 'Frame.crop moves the offsets'); + const bordered = new Frame(new Image(10, 10).fill((x, y) => x >= 3 && y >= 2 && x < 7 && y < 6 ? 0xff0000ff : 0), {xOffset: 1, yOffset: 1}); + bordered.trim(); + assert(bordered.width === 4 && bordered.height === 4 && bordered.xOffset === 4 && bordered.yOffset === 3, 'Frame.trim moves the offsets'); + const copy = frame.clone(); + assert(copy.image !== frame.image && copy.xOffset === 13 && copy.duration === 40 && Buffer.from(copy.image.bitmap).equals(Buffer.from(frame.image.bitmap)), 'Frame.clone copies the image'); + frame.image = new Image(3, 3); + assert(frame.width === 3 && frame.xOffset === 13, 'Frame.image can be replaced'); + for (const bad of [null, {width: 2, height: 2}, frame]) { + let error = null; + try { new Frame(bad); } catch (e) { error = e; } + assert(error instanceof TypeError, `new Frame(${bad}) should throw a TypeError`); + } + const encoder = Animation.apngEncoder(30, 20); + await encoder.add(copy); + await encoder.add(new Image(4, 4).fill(0xffffffff), {duration: 20}); + const decoded = await Animation.decode(await encoder.finish()); + assert(decoded.length === 2 && decoded.frames[0].duration === 40, 'encoder.add takes frames and images'); + } + // renamed parameters and the American spelling { const gray = new Image(1, 1).fill(0xff0000ff).grayscale(); diff --git a/tests/avif.js b/tests/avif.js index fd18d316..8f0c3393 100644 --- a/tests/avif.js +++ b/tests/avif.js @@ -68,7 +68,7 @@ const references = { // image sequences decode as their first frame const animation = await Animation.decode(await fs.readFile('./tests/avif/sequence.avif')); - if (animation.length !== 1 || sha(animation.frames[0]) !== references.sequence) panic('sequence: Animation.decode should give its first frame'); + if (animation.length !== 1 || sha(animation.frames[0].image) !== references.sequence) panic('sequence: Animation.decode should give its first frame'); // invalid data throws const binary = await fs.readFile('./tests/avif/yuv420.avif'); diff --git a/tests/color-matrix.js b/tests/color-matrix.js index 8bd3ba8f..456e2cbd 100644 --- a/tests/color-matrix.js +++ b/tests/color-matrix.js @@ -52,8 +52,8 @@ const IDENTITY = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0]; check(Buffer.from(image.bitmap).equals(Buffer.from(before)), `colorMatrix with ${label}: the image should be unchanged`); } - const frame = Frame.from(photo(4, 4), {duration: 40, xOffset: 2, yOffset: 3}).colorMatrix(sepia); - check(frame instanceof Frame && frame.duration === 40 && frame.xOffset === 2 && frame.yOffset === 3 && same(frame, photo(4, 4).sepia()), 'colorMatrix on a frame'); + const frame = new Frame(photo(4, 4).colorMatrix(sepia), {duration: 40, xOffset: 2, yOffset: 3}); + check(frame instanceof Frame && frame.duration === 40 && frame.xOffset === 2 && frame.yOffset === 3 && same(frame.image, photo(4, 4).sepia()), 'colorMatrix on a frame'); const hdr = photo(8, 8); hdr.gainMap = new GainMap(new Image(4, 4).fill(0xc0c0c0ff), {min: 0, max: 2}); diff --git a/tests/crop.js b/tests/crop.js index 08689d9a..26d28cad 100644 --- a/tests/crop.js +++ b/tests/crop.js @@ -35,8 +35,9 @@ const samePixels = require('./util/pixels'); try { source.region(600, 0, 10, 10); } catch (err) { threw = err instanceof RangeError; } if (!threw) panic('region: a region outside the image should throw a RangeError'); - // a frame's region is a plain image, the frame stays as it is - const frame = Frame.from(source, {duration: 100, xOffset: 4, yOffset: 6}); - const part = frame.region(0, 0, 10, 10); - if (part instanceof Frame || part.width !== 10 || frame.width !== 512 || frame.xOffset !== 4) panic('region: frames'); + // a frame's crop moves its offsets, so the pixels stay where they are in the animation + const frame = new Frame(source.clone(), {duration: 100, xOffset: 4, yOffset: 6}); + const pixel = source.getPixelAt(30, 20); + frame.crop(20, 10, 40, 30); + if (frame.width !== 40 || frame.height !== 30 || frame.xOffset !== 24 || frame.yOffset !== 16 || frame.image.getPixelAt(10, 10) !== pixel) panic('crop: frames'); })(); diff --git a/tests/decode-frames.js b/tests/decode-frames.js index 8dfafc73..c7184f23 100644 --- a/tests/decode-frames.js +++ b/tests/decode-frames.js @@ -15,7 +15,7 @@ const throwsA = async (fn, type, label) => { (async () => { const photo = new Image(12, 8).fill((x, y) => Colors.fromRGBA(x * 20, y * 30, 90, 255)); - const two = new Animation([Frame.from(photo, {duration: 50}), Frame.from(photo.clone().invert(), {duration: 70})]); + const two = new Animation([new Frame(photo.clone(), {duration: 50}), new Frame(photo.clone().invert(), {duration: 70})]); // multiple frames: an Animation, with every frame const gif = await fs.readFile('./tests/targets/gif.gif'); @@ -38,17 +38,17 @@ const throwsA = async (fn, type, label) => { if (!(jxlFrames instanceof Animation) || jxlFrames.length !== jxlRef.length) panic(`JPEG XL: ${jxlFrames.constructor.name} with ${jxlFrames.length} frames instead of ${jxlRef.length}`); // one frame: an Image with the frame's pixels, as Animation.decode's first frame has them - const single = new Animation([new Frame(12, 8, {duration: 90, xOffset: 0, yOffset: 0}).fill((x, y) => Colors.fromRGBA(x * 20, y * 30, 90, 255))]); + const single = new Animation([new Frame(new Image(12, 8).fill((x, y) => Colors.fromRGBA(x * 20, y * 30, 90, 255)), {duration: 90, xOffset: 0, yOffset: 0})]); for (const [format, bytes] of [['GIF', await single.encodeGIF()], ['APNG', await single.encodeAPNG()], ['WebP', await single.encodeWebP({quality: 90})]]) { const image = await decode(bytes); if (image instanceof Animation || !(image instanceof Image)) panic(`single frame ${format}: ${image.constructor.name} instead of an Image`); - const reference = (await Animation.decode(bytes)).frames[0]; + const reference = (await Animation.decode(bytes)).frames[0].image; if (image.width !== reference.width || image.height !== reference.height) panic(`single frame ${format}: ${image.width}x${image.height} instead of ${reference.width}x${reference.height}`); if (sha256(image.bitmap) !== sha256(reference.bitmap)) panic(`single frame ${format}: pixels differ from Animation.decode's first frame`); } // a single frame with an offset: the image is the canvas, the offset is dropped - const offset = await new Animation([new Frame(4, 3, {xOffset: 2, yOffset: 1, duration: 40}).fill(0xff0000ff)]).encodeGIF({optimize: false}); + const offset = await new Animation([new Frame(new Image(4, 3).fill(0xff0000ff), {xOffset: 2, yOffset: 1, duration: 40})]).encodeGIF({optimize: false}); const offsetImage = await decode(offset); if (offsetImage instanceof Animation || offsetImage.width !== 6 || offsetImage.height !== 4) panic(`single frame with an offset: ${offsetImage.width}x${offsetImage.height}`); @@ -68,7 +68,7 @@ const throwsA = async (fn, type, label) => { // options pass through: Image.decode gives the first frame as an Image, maxPixels applies to both kinds const first = await Image.decode(gif); if (first instanceof Animation || !(first instanceof Image)) panic('Image.decode: expected an Image'); - if (sha256(first.bitmap) !== sha256((await Animation.decode(gif)).frames[0].bitmap)) panic('Image.decode: wrong frame'); + if (sha256(first.bitmap) !== sha256((await Animation.decode(gif)).frames[0].image.bitmap)) panic('Image.decode: wrong frame'); await throwsA(() => decode(gif, {maxPixels: 100}), RangeError, 'maxPixels on a GIF'); const jpeg = await fs.readFile('./tests/targets/external.jpg'); await throwsA(() => decode(jpeg, {maxPixels: 100}), RangeError, 'maxPixels on a JPEG'); @@ -140,18 +140,18 @@ const throwsA = async (fn, type, label) => { const oneAnimation = await Animation.decode(oneApng); if (!(oneAnimation instanceof Animation) || oneAnimation.length !== 1) panic(`one-frame APNG with acTL: Animation.decode gives ${oneAnimation.constructor.name} with ${oneAnimation.length} frames`); - if (sha256(oneAnimation.frames[0].bitmap) !== sha256((await Animation.decode(apng)).frames[0].bitmap)) panic('one-frame APNG with acTL: the frame differs from the first frame of the two-frame file'); + if (sha256(oneAnimation.frames[0].image.bitmap) !== sha256((await Animation.decode(apng)).frames[0].image.bitmap)) panic('one-frame APNG with acTL: the frame differs from the first frame of the two-frame file'); const oneImage = await decode(oneApng); if (oneImage instanceof Animation || !(oneImage instanceof Image)) panic('one-frame APNG with acTL: decode() should give an Image'); - if (sha256(oneImage.bitmap) !== sha256(oneAnimation.frames[0].bitmap)) panic('one-frame APNG with acTL: decode() pixels differ from Animation.decode'); + if (sha256(oneImage.bitmap) !== sha256(oneAnimation.frames[0].image.bitmap)) panic('one-frame APNG with acTL: decode() pixels differ from Animation.decode'); // Image.decode of an animation is its first frame, an Image composed like Animation.decode's first frame: frames that // differ, with offsets and disposal modes, from our encoders (GIF, APNG, animated WebP, lossless and lossy) const moving = new Animation([ - new Frame(12, 8, {duration: 50}).fill((x, y) => Colors.fromRGBA(x * 20, y * 30, 90, 255)), - new Frame(5, 4, {duration: 70, xOffset: 3, yOffset: 2, disposalMode: 'background'}).fill((x, y) => Colors.fromRGBA(255, x * 50, y * 60, 128)), - new Frame(6, 3, {duration: 40, xOffset: 1, yOffset: 4, disposalMode: 'previous'}).fill(0x00ff00ff), - new Frame(4, 4, {duration: 60, xOffset: 8, yOffset: 4}).fill((x, y) => Colors.fromRGBA(y * 90, 0, 200, 255 - x * 60)) + new Frame(new Image(12, 8).fill((x, y) => Colors.fromRGBA(x * 20, y * 30, 90, 255)), {duration: 50}), + new Frame(new Image(5, 4).fill((x, y) => Colors.fromRGBA(255, x * 50, y * 60, 128)), {duration: 70, xOffset: 3, yOffset: 2, disposalMode: 'background'}), + new Frame(new Image(6, 3).fill(0x00ff00ff), {duration: 40, xOffset: 1, yOffset: 4, disposalMode: 'previous'}), + new Frame(new Image(4, 4).fill((x, y) => Colors.fromRGBA(y * 90, 0, 200, 255 - x * 60)), {duration: 60, xOffset: 8, yOffset: 4}) ]); const movingFiles = { GIF: await moving.encodeGIF(), @@ -166,10 +166,10 @@ const throwsA = async (fn, type, label) => { if (image.constructor !== Image) panic(`${name}: Image.decode gives ${image.constructor.name}, not an Image`); const frames = (await Animation.decode(bytes)).frames; if (frames.length !== 4) panic(`${name}: ${frames.length} frames instead of 4`); - const reference = frames[0]; + const reference = frames[0].image; if (image.width !== reference.width || image.height !== reference.height) panic(`${name}: ${image.width}x${image.height} instead of ${reference.width}x${reference.height}`); if (sha256(image.bitmap) !== sha256(reference.bitmap)) panic(`${name}: Image.decode is not the first frame of Animation.decode`); - if (sha256(image.bitmap) === sha256(frames[1].bitmap)) panic(`${name}: the frames should differ`); + if (sha256(image.bitmap) === sha256(frames[1].image.bitmap)) panic(`${name}: the frames should differ`); } // options apply to the first frame: maxPixels counts the canvas await throwsA(() => Image.decode(movingFiles.GIF, {maxPixels: 10}), RangeError, 'Image.decode maxPixels of an animated GIF'); @@ -210,10 +210,10 @@ const throwsA = async (fn, type, label) => { const shown = await Image.decode(separate); if (shown.constructor !== Image || sha256(shown.bitmap) !== sha256((await Image.decode(defaultPNG)).bitmap)) panic('APNG with a separate default image: Image.decode should give the default image'); const animated = await Animation.decode(separate); - if (animated.length !== 1 || sha256(animated.frames[0].bitmap) !== sha256((await Image.decode(framePNG)).bitmap)) panic('APNG with a separate default image: Animation.decode should give only the animation\'s frame'); + if (animated.length !== 1 || sha256(animated.frames[0].image.bitmap) !== sha256((await Image.decode(framePNG)).bitmap)) panic('APNG with a separate default image: Animation.decode should give only the animation\'s frame'); // a large animation: Image.decode stops after its first frame, so it takes a fraction of the time of all 50 frames - const many = new Animation(Array.from({length: 50}, (_, i) => new Frame(480, 360, {duration: 40}).fill((x, y) => Colors.fromRGBA((x * 7 + i * 13) & 255, (y * 5 + x * i) & 255, (x ^ y) & 255, 255)))); + const many = new Animation(Array.from({length: 50}, (_, i) => new Frame(new Image(480, 360).fill((x, y) => Colors.fromRGBA((x * 7 + i * 13) & 255, (y * 5 + x * i) & 255, (x ^ y) & 255, 255)), {duration: 40}))); const large = await many.encodeGIF(); const median = async fn => { const times = []; diff --git a/tests/edge/smoke.mjs b/tests/edge/smoke.mjs index 4fd63262..990ade56 100644 --- a/tests/edge/smoke.mjs +++ b/tests/edge/smoke.mjs @@ -28,7 +28,7 @@ export async function smoke() { await step('jxl lossless', async () => back(await alpha.encodeJXL())); await step('jxl lossy', async () => back(await alpha.encodeJXL({ quality: 85, effort: 2 }))); await step('tga, bmp, qoi, pnm', async () => (await Promise.all([photo.encodeTGA(), photo.encodeBMP(), photo.encodeQOI(), photo.encodePNM()].map(async d => back(await d)))).join(', ')); - const frames = [Frame.from(photo.clone().resize(64, 64), {duration: 100}), Frame.from(photo.clone().resize(64, 64).invert(), {duration: 100})]; + const frames = [new Frame(photo.clone().resize(64, 64), {duration: 100}), new Frame(photo.clone().resize(64, 64).invert(), {duration: 100})]; for (const [name, encode] of [['gif', a => a.encodeGIF()], ['apng', a => a.encodeAPNG()], ['animated webp', a => a.encodeWebP({ quality: 70 })]]) { await step(name, async () => { const decoded = await Animation.decode(await encode(new Animation(frames))); diff --git a/tests/gif.js b/tests/gif.js index afd04cf2..ad7f67a8 100644 --- a/tests/gif.js +++ b/tests/gif.js @@ -10,8 +10,8 @@ const panic = message => { (async () => { const frames = []; for (let i = 0; i < 30; i++) { - const frame = new Frame(256, 256); - frame.fill(x => Colors.fromHSL((x / frame.width + i / 30) * 360, 1, 0.5)); + const frame = new Frame(new Image(256, 256)); + frame.image.fill(x => Colors.fromHSL((x / frame.width + i / 30) * 360, 1, 0.5)); frames.push(frame); } @@ -31,7 +31,7 @@ const panic = message => { panic('decode: incorrect frame count'); for (let i = 0; i < gif.length; i++) { - if (gif.frames[i].bitmap.length !== decoded.frames[i].bitmap.length) + if (gif.frames[i].image.bitmap.length !== decoded.frames[i].image.bitmap.length) panic('decode: incorrect frame bitmap length'); } @@ -44,7 +44,7 @@ const panic = message => { // frames with <= 256 colors survive a roundtrip losslessly const decoded = await Animation.decode(await gif.encodeGIF()); for (let i = 0; i < gif.length; i++) - if (!Buffer.from(decoded.frames[i].bitmap).equals(Buffer.from(gif.frames[i].bitmap))) + if (!Buffer.from(decoded.frames[i].image.bitmap).equals(Buffer.from(gif.frames[i].image.bitmap))) panic(`roundtrip: frame ${i} differs`); if (decoded.frames[0].duration !== gif.frames[0].duration) panic('roundtrip: incorrect duration'); @@ -57,14 +57,14 @@ const panic = message => { const black = Colors.fromRGBA(0, 0, 0, 255); const white = Colors.fromRGBA(255, 255, 255, 255); - const background = new Frame(4, 4, {duration: 100}); - background.fill(black); - const dot = new Frame(1, 1, {duration: 100, xOffset: 1, yOffset: 1, disposalMode: 'previous'}); - dot.fill(white); - const hole = new Frame(2, 2, {duration: 100, xOffset: 2, yOffset: 2, disposalMode: 'background'}); - hole.fill((x, y) => x === y ? white : 0); - const last = new Frame(1, 1, {duration: 100, xOffset: 0, yOffset: 0}); - last.fill(white); + const background = new Frame(new Image(4, 4), {duration: 100}); + background.image.fill(black); + const dot = new Frame(new Image(1, 1), {duration: 100, xOffset: 1, yOffset: 1, disposalMode: 'previous'}); + dot.image.fill(white); + const hole = new Frame(new Image(2, 2), {duration: 100, xOffset: 2, yOffset: 2, disposalMode: 'background'}); + hole.image.fill((x, y) => x === y ? white : 0); + const last = new Frame(new Image(1, 1), {duration: 100, xOffset: 0, yOffset: 0}); + last.image.fill(white); const decoded = await Animation.decode(await new Animation([background, dot, hole, last], {loopCount: 2}).encodeGIF()); if (decoded.loopCount !== 2) @@ -74,7 +74,7 @@ const panic = message => { for (let y = 0; y < 4; y++) for (let x = 0; x < 4; x++) { const want = pixels[y * 4 + x]; - const got = decoded.frames[frame].getPixelAt(x, y); + const got = decoded.frames[frame].image.getPixelAt(x, y); if (want === 'B' ? got !== black : want === 'W' ? got !== white : got !== 0) panic(`disposal: frame ${frame} pixel ${x},${y} is ${got.toString(16)}`); } @@ -90,7 +90,7 @@ const panic = message => { { const [frame] = (await Animation.decode(await fs.readFile('./tests/gifs/interlaced.gif'))).frames; if (frame.width !== 80 || frame.height !== 37) panic('interlaced: dimensions don\'t match'); - if (crypto.createHash('sha256').update(new Uint8Array(frame.bitmap)).digest('hex') !== '854e5e98f2124a6009d5bc28748772ede25af244a2503021ef4f52e323309d46') + if (crypto.createHash('sha256').update(new Uint8Array(frame.image.bitmap)).digest('hex') !== '854e5e98f2124a6009d5bc28748772ede25af244a2503021ef4f52e323309d46') panic('interlaced: pixels don\'t match'); } diff --git a/tests/icc.js b/tests/icc.js index c73439d5..46bdb2e0 100644 --- a/tests/icc.js +++ b/tests/icc.js @@ -53,11 +53,11 @@ const difference = (a, b) => { const animation = await Animation.decode(await fs.readFile('./tests/icc/display-p3-animated.webp')); for (const i of [0, 1]) { const ref = await Image.decode(await fs.readFile(`./tests/icc/display-p3-animated-${i + 1}.ref.png`)); - if (difference(animation.frames[i], ref) > 1) panic(`animated WebP frame ${i + 1}: differs from LittleCMS`); + if (difference(animation.frames[i].image, ref) > 1) panic(`animated WebP frame ${i + 1}: differs from LittleCMS`); } // (JPEG XLs through Animation.decode too) const jxlFrames = await Animation.decode(await fs.readFile('./tests/icc/display-p3.jxl')); - if (difference(jxlFrames.frames[0], await Image.decode(await fs.readFile('./tests/icc/display-p3.ref.png'))) > 1) panic('JPEG XL through Animation.decode: differs from LittleCMS'); + if (difference(jxlFrames.frames[0].image, await Image.decode(await fs.readFile('./tests/icc/display-p3.ref.png'))) > 1) panic('JPEG XL through Animation.decode: differs from LittleCMS'); // lossless JPEG XLs in an enumerated color space (no ICC profile, like cjxl writes Display P3): converted to sRGB // like an embedded profile. The Display P3 values, losslessly encoded, with the image header's color encoding @@ -72,7 +72,7 @@ const difference = (a, b) => { const p3Decoded = await Image.decode(enumerated); if (difference(p3Decoded, p3Ref) > 1) panic(`enumerated Display P3 JPEG XL: differs from LittleCMS by ${difference(p3Decoded, p3Ref)}`); if (difference(await Image.decode(enumerated, {applyColorProfile: false}), p3) !== 0) panic('enumerated Display P3 JPEG XL, applyColorProfile: false: should keep the stored colors'); - if (difference((await Animation.decode(enumerated)).frames[0], p3Ref) > 1) panic('enumerated Display P3 JPEG XL through Animation.decode: differs from LittleCMS'); + if (difference((await Animation.decode(enumerated)).frames[0].image, p3Ref) > 1) panic('enumerated Display P3 JPEG XL through Animation.decode: differs from LittleCMS'); })(); // the codestream of a JPEG XL in the container (our encoder's: one jxlc box) diff --git a/tests/jxl.js b/tests/jxl.js index 6467ca29..21d9e71b 100644 --- a/tests/jxl.js +++ b/tests/jxl.js @@ -44,7 +44,7 @@ const difference = (bitmap, width, height, reference, step = 1, premultiplied = if (animation.length !== references.length) panic(`${name}: ${animation.length} frames instead of ${references.length}`); animation.frames.forEach((frame, i) => { if (frame.duration !== references.frames[i].duration) panic(`${name}: frame ${i} lasts ${frame.duration} ms instead of ${references.frames[i].duration}`); - max = Math.max(max, difference(frame.bitmap, frame.width, frame.height, references.frames[i])); + max = Math.max(max, difference(frame.image.bitmap, frame.width, frame.height, references.frames[i].image)); }); } if (max > (premultiplied ? 2 : 1)) panic(`${name}: differs by ${max} from djxl's`); @@ -53,7 +53,7 @@ const difference = (bitmap, width, height, reference, step = 1, premultiplied = // the first frame only; Animation.decode of a still image const animated = await fs.readFile('./tests/jxl/animation.jxl'); const first = await Image.decode(animated); - if (first.constructor !== Image || !Buffer.from(first.bitmap).equals(Buffer.from((await Animation.decode(animated)).frames[0].bitmap))) panic('Image.decode: the first frame of an animated JPEG XL should be Animation.decode\'s first frame'); + if (first.constructor !== Image || !Buffer.from(first.bitmap).equals(Buffer.from((await Animation.decode(animated)).frames[0].image.bitmap))) panic('Image.decode: the first frame of an animated JPEG XL should be Animation.decode\'s first frame'); const still = await Animation.decode(await fs.readFile('./tests/jxl/lossy.jxl')); if (still.length !== 1 || still.frames[0].width !== 64) panic('a still JPEG XL as an animation'); @@ -76,7 +76,7 @@ const difference = (bitmap, width, height, reference, step = 1, premultiplied = // (not in shared memory, which some APIs reject) if (four.bitmap.buffer instanceof SharedArrayBuffer) panic('threads: the bitmap is in shared memory'); const animation = await Animation.decode(data, {threads: 4}); - if (Buffer.compare(Buffer.from(animation.frames[0].bitmap), Buffer.from(one.bitmap))) panic('threads, Animation.decode: different pixels'); + if (Buffer.compare(Buffer.from(animation.frames[0].image.bitmap), Buffer.from(one.bitmap))) panic('threads, Animation.decode: different pixels'); } for (const threads of [0, 1.5]) if (!await Image.decode(lossy, {threads}).then(() => false, e => e instanceof RangeError)) panic(`threads ${threads} should throw`); })(); diff --git a/tests/loop-count.js b/tests/loop-count.js index 624bdc32..0cfed428 100644 --- a/tests/loop-count.js +++ b/tests/loop-count.js @@ -10,7 +10,7 @@ const throwsA = (fn, type, label) => { if (!(error instanceof type)) panic(`${label}: should throw a ${type.name}, got ${error}`); }; -const frames = () => [Frame.from(new Image(4, 4).fill(0xff0000ff), {duration: 40}), Frame.from(new Image(4, 4).fill(0x00ff00ff), {duration: 60})]; +const frames = () => [new Frame(new Image(4, 4).fill(0xff0000ff), {duration: 40}), new Frame(new Image(4, 4).fill(0x00ff00ff), {duration: 60})]; // each format: its encode method, its encoder factory, and the count the file stores when it loops forever or plays // (the GIF's NETSCAPE block counts repetitions, APNG and WebP count plays) const formats = {GIF: ['encodeGIF', 'gifEncoder'], APNG: ['encodeAPNG', 'apngEncoder'], WebP: ['encodeWebP', 'webpEncoder']}; diff --git a/tests/metadata.js b/tests/metadata.js index c0d6eda0..70777c16 100644 --- a/tests/metadata.js +++ b/tests/metadata.js @@ -12,7 +12,7 @@ const latin1 = (data, text) => Buffer.from(data).includes(Buffer.from(text, 'lat const {VERSION} = await import('../dist/version.js'); const software = `ImageScript v${VERSION}`; const image = new Image(8, 4).fill(0xff000080); - const animation = new Animation([Frame.from(image, {duration: 100}), Frame.from(new Image(8, 4).fill(0x00ff00ff), {duration: 100})]); + const animation = new Animation([new Frame(image.clone(), {duration: 100}), new Frame(new Image(8, 4).fill(0x00ff00ff), {duration: 100})]); const encoders = { png: m => image.encodePNG({metadata: m}), diff --git a/tests/misc.js b/tests/misc.js index c70107db..438f2454 100644 --- a/tests/misc.js +++ b/tests/misc.js @@ -159,18 +159,18 @@ if (Colors.fromHSLA(0, 0, 1, 0xff) !== 0xffffffff) } try { - new Frame(1, 1, {duration: -1}); + new Frame(new Image(1, 1), {duration: -1}); panic('frame duration failed'); } catch { } -if (new Frame(1, 2, {duration: 3}).toString() !== 'Frame<1x2x3ms>') panic('frame toString failed'); +if (new Frame(new Image(1, 2), {duration: 3}).toString() !== 'Frame<1x2x3ms>') panic('frame toString failed'); { const image = new Image(512, 512); - Frame.from(image, {duration: 10}); + new Frame(image.clone(), {duration: 10}); try { - Frame.from({}, {duration: 10}); + new Frame({}, {duration: 10}); panic('frame instanceof image failed'); } catch { } @@ -183,7 +183,7 @@ try { } { - const frame = new Frame(512, 128, {duration: 123}); + const frame = new Frame(new Image(512, 128), {duration: 123}); const animation = new Animation([frame]); if (animation.toString() !== 'Animation<512x128x123ms>') panic('animation tostring failed'); if (animation.duration !== 123) panic('animation duration failed'); diff --git a/tests/option-checks.js b/tests/option-checks.js index 52162015..9e8580f5 100644 --- a/tests/option-checks.js +++ b/tests/option-checks.js @@ -19,9 +19,9 @@ const rejects = async (name, fn, Type, pattern) => { (async () => { const image = new Image(8, 8).fill(0x336699ff); - const animation = () => new Animation([new Frame(8, 8, {duration: 100}).fill(0xff0000ff), new Frame(8, 8, {duration: 100}).fill(0x00ff00ff)]); + const animation = () => new Animation([new Frame(new Image(8, 8).fill(0xff0000ff), {duration: 100}), new Frame(new Image(8, 8).fill(0x00ff00ff), {duration: 100})]); const encoded = async (encoder) => { - await encoder.add(new Frame(8, 8).fill(0xff0000ff)); + await encoder.add(new Frame(new Image(8, 8).fill(0xff0000ff))); return encoder.finish(); }; @@ -52,7 +52,7 @@ const rejects = async (name, fn, Type, pattern) => { await rejects('apngEncoder compression NaN', () => encoded(Animation.apngEncoder(8, 8, {compression: NaN})), RangeError, /compression level/); if (!(await animation().encodeAPNG({compression: 12})).length) panic('clamped values rejected'); - await rejects('Animation loopCount 1.5', () => new Animation([new Frame(8, 8)], {loopCount: 1.5}), RangeError, /loop count/); + await rejects('Animation loopCount 1.5', () => new Animation([new Frame(new Image(8, 8))], {loopCount: 1.5}), RangeError, /loop count/); await rejects('dominantColor threshold NaN', () => image.dominantColor({threshold: NaN}), RangeError, /threshold/); await rejects('renderSVG scale NaN', () => Image.renderSVG('', {scale: NaN}), RangeError, /SVG scale/); await rejects('renderText size -1', () => Image.renderText('x', { font: new Uint8Array(0), size: -1 }), RangeError, /font size/); @@ -64,7 +64,7 @@ const rejects = async (name, fn, Type, pattern) => { await rejects('Animation.decode threads 0', () => Animation.decode(png, {threads: 0}), RangeError, /threads must be/); // Image.decode takes the options object of a GIF too: maxPixels counts its first frame - const gif = await new Animation([new Frame(4, 4).fill(0xff0000ff)]).encodeGIF(); + const gif = await new Animation([new Frame(new Image(4, 4).fill(0xff0000ff))]).encodeGIF(); await rejects('Image.decode GIF maxPixels', () => Image.decode(gif, {maxPixels: 15}), RangeError, /maxPixels/); if ((await Image.decode(gif, {maxPixels: 16})).width !== 4) panic('Image.decode GIF: the first frame should fit maxPixels 16'); diff --git a/tests/options-objects-all.js b/tests/options-objects-all.js index 5dae68df..0610e4be 100644 --- a/tests/options-objects-all.js +++ b/tests/options-objects-all.js @@ -9,7 +9,7 @@ const panic = message => { // call in a form that doesn't exist, so it throws instead of being ignored (async () => { const photo = new Image(16, 10).fill((x, y) => 0x336699ff + x * 0x01000000 + y * 0x00010000 >>> 0); - const frame = Frame.from(photo); + const frame = new Frame(photo.clone()); const gainMap = new GainMap(new Image(4, 3).fill(0x808080ff), {max: 2}); const calls = { 'quantize': bad => photo.clone().quantize(bad), diff --git a/tests/options-only.js b/tests/options-only.js index d3911fdf..73f9cd3d 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -33,10 +33,10 @@ const sameImage = (image, reference, label) => { const scaled = photo.clone(); await rejects(() => scaled.scale(0.5, mode), `scale(0.5, ${label})`); sameImage(scaled, photo, `scale(0.5, ${label})`); - const frame = new Frame(20, 12, {duration: 50}); + const frame = new Frame(new Image(20, 12), {duration: 50}); await rejects(() => frame.resize(10, 6, mode), `Frame.resize(10, 6, ${label})`); if (frame.width !== 20 || frame.height !== 12) panic(`Frame.resize(10, 6, ${label}): the frame should keep its size`); - const animation = new Animation([new Frame(20, 12)]); + const animation = new Animation([new Frame(new Image(20, 12))]); await rejects(() => animation.resize(10, 6, mode), `Animation.resize(10, 6, ${label})`); if (animation.frames[0].width !== 20 || animation.frames[0].height !== 12) panic(`Animation.resize(10, 6, ${label}): the frames should keep their size`); } @@ -67,7 +67,7 @@ const sameImage = (image, reference, label) => { sameImage(image, photo, label); } { - const frame = new Frame(20, 12, {xOffset: 4, yOffset: 5}); + const frame = new Frame(new Image(20, 12), {xOffset: 4, yOffset: 5}); await rejects(() => frame.trim(0xffffffff), 'Frame.trim(0xffffffff)'); if (frame.xOffset !== 4 || frame.yOffset !== 5 || frame.width !== 20) panic('Frame.trim(0xffffffff): the frame should be unchanged'); } @@ -79,7 +79,7 @@ const sameImage = (image, reference, label) => { if (solid.clone().rotate(90, {expand: false}).width !== 6) panic('rotate(90, { expand: false })'); // the encoders take their values in the options object only; the metadata goes in { metadata } - const makeAnimation = () => new Animation([new Frame(4, 4)]); + const makeAnimation = () => new Animation([new Frame(new Image(4, 4))]); const gainMap = new GainMap(new Image(4, 4).fill(0x808080ff), {min: 0, max: 2}); for (const [label, call] of Object.entries({ 'encodePNG(1)': () => photo.encodePNG(1), @@ -111,17 +111,17 @@ const sameImage = (image, reference, label) => { if (byWidth.width !== 30 || byWidth.height !== 30) panic(`renderSVG(svg, { width }): ${byWidth.width}x${byWidth.height}`); // frames and animations take their duration, offsets, disposal mode and loop count in the options object only - const frame = new Frame(4, 4, {duration: 40, xOffset: 2, yOffset: 3, disposalMode: 'background'}); - if (frame.duration !== 40 || frame.xOffset !== 2 || frame.yOffset !== 3 || frame.disposalMode !== 'background') panic('new Frame(width, height, options)'); - if (Frame.from(photo, {duration: 40, xOffset: 2}).xOffset !== 2 || Frame.from(photo).duration !== 100) panic('Frame.from(image, options)'); - if (new Animation([new Frame(4, 4)], {loopCount: 2}).loopCount !== 2 || new Animation([new Frame(4, 4)]).loopCount !== Infinity) panic('new Animation(frames, options)'); - const encodedGIF = await new Animation([new Frame(4, 4)]).encodeGIF(); + const frame = new Frame(new Image(4, 4), {duration: 40, xOffset: 2, yOffset: 3, disposalMode: 'background'}); + if (frame.duration !== 40 || frame.xOffset !== 2 || frame.yOffset !== 3 || frame.disposalMode !== 'background') panic('new Frame(new Image(width, height), options)'); + if (new Frame(photo.clone(), {duration: 40, xOffset: 2}).xOffset !== 2 || new Frame(photo.clone()).duration !== 100) panic('new Frame(image.clone(), options)'); + if (new Animation([new Frame(new Image(4, 4))], {loopCount: 2}).loopCount !== 2 || new Animation([new Frame(new Image(4, 4))]).loopCount !== Infinity) panic('new Animation(frames, options)'); + const encodedGIF = await new Animation([new Frame(new Image(4, 4))]).encodeGIF(); if (!((await Image.decode(encodedGIF)) instanceof Image)) panic('Image.decode(data) should return the first frame as an Image'); const oldFrames = { - 'new Frame(4, 4, 100)': () => new Frame(4, 4, 100), - 'new Frame(4, 4, 100, 1, 1, "keep")': () => new Frame(4, 4, 100, 1, 1, 'keep'), - 'Frame.from(photo, 100)': () => Frame.from(photo, 100), - 'Frame.from(photo, 100, 1, 1, "keep")': () => Frame.from(photo, 100, 1, 1, 'keep'), + 'new Frame(new Image(4, 4), 100)': () => new Frame(new Image(4, 4), 100), + 'new Frame(new Image(4, 4), 100, 1, 1, "keep")': () => new Frame(new Image(4, 4), 100, 1, 1, 'keep'), + 'new Frame(photo.clone(), 100)': () => new Frame(photo.clone(), 100), + 'new Frame(photo.clone(), 100, 1, 1, "keep")': () => new Frame(photo.clone(), 100, 1, 1, 'keep'), 'new Animation([frame], 2)': () => new Animation([frame], 2), 'new Animation([frame], Infinity)': () => new Animation([frame], Infinity), 'Animation.decode(gif, true)': () => Animation.decode(encodedGIF, true), @@ -144,6 +144,6 @@ const sameImage = (image, reference, label) => { if (photo.clone().resize({width: 10, height: 10, fit: 'inside', mode: 'area'}).width !== 10) panic('resize({ width, height, fit: \'inside\', mode })'); if (photo.clone().contain(10, 10, {mode: 'area'}).height !== 10) panic('contain(10, 10, { mode })'); if (photo.clone().cover(10, 10, {mode: 'area'}).width !== 10) panic('cover(10, 10, { mode })'); - if (new Frame(20, 12).resize(10, 6, {mode: 'area'}).width !== 10) panic('Frame.resize(10, 6, { mode })'); - if (new Animation([new Frame(20, 12)]).resize(10, 6, {mode: 'area'}).frames[0].width !== 10) panic('Animation.resize(10, 6, { mode })'); + if (new Frame(new Image(20, 12)).resize(10, 6, {mode: 'area'}).width !== 10) panic('Frame.resize(10, 6, { mode })'); + if (new Animation([new Frame(new Image(20, 12))]).resize(10, 6, {mode: 'area'}).frames[0].width !== 10) panic('Animation.resize(10, 6, { mode })'); })().catch(error => panic(error.stack || String(error))); diff --git a/tests/options.js b/tests/options.js index 3d504e5b..c97f028d 100644 --- a/tests/options.js +++ b/tests/options.js @@ -35,13 +35,13 @@ const same = (name, a, b) => { const svg = await fs.readFile('./tests/svgs/crown.svg'); // frames and animations - const b = new Frame(8, 6, {duration: 40, xOffset: 2, yOffset: 3, disposalMode: 'background'}); - const c = Frame.from(logo, {duration: 40, xOffset: 2, yOffset: 3, disposalMode: 'background'}); + const b = new Frame(new Image(8, 6), {duration: 40, xOffset: 2, yOffset: 3, disposalMode: 'background'}); + const c = new Frame(logo.clone(), {duration: 40, xOffset: 2, yOffset: 3, disposalMode: 'background'}); for (const frame of [b, c]) if (frame.duration !== 40 || frame.xOffset !== 2 || frame.yOffset !== 3 || frame.disposalMode !== 'background') panic('Frame: options not applied'); - if (new Frame(1, 1, {}).duration !== 100 || new Frame(1, 1, {}).disposalMode !== 'keep') panic('Frame: wrong defaults'); + if (new Frame(new Image(1, 1), {}).duration !== 100 || new Frame(new Image(1, 1), {}).disposalMode !== 'keep') panic('Frame: wrong defaults'); - const frames = () => [Frame.from(tile(), {duration: 50}), Frame.from(tile().invert(), {duration: 50})]; + const frames = () => [new Frame(tile(), {duration: 50}), new Frame(tile().invert(), {duration: 50})]; if (new Animation(frames(), {loopCount: 2}).loopCount !== 2) panic('Animation: loopCount not applied'); const animation = new Animation(frames()); // (GIFs only store comments) diff --git a/tests/palette.js b/tests/palette.js index 79dab48d..ac7fe7a1 100644 --- a/tests/palette.js +++ b/tests/palette.js @@ -141,13 +141,6 @@ const blockError = (original, result) => { const thin = photo(1, 9).quantize({colors: 3, dither: 'floyd-steinberg'}); if (thin.width !== 1 || thin.height !== 9 || colorsOf(thin).size > 3) panic('1x9 quantize'); - // frames: quantize their own pixels and return the frame - { - const frame = new Frame(20, 10, {duration: 100}).fill((x, y) => Colors.fromRGBA(x * 12, y * 25, 128, 255)); - if (frame.quantize({colors: 2, dither: 'ordered'}) !== frame) panic('quantize does not return this for a frame'); - if (colorsOf(frame).size > 2) panic('frame quantize'); - } - // an image with a gain map keeps a valid one { const hdr = new Image(8, 8).fill((x, y) => Colors.fromRGBA(x * 30, y * 30, 200, 255)); diff --git a/tests/png.js b/tests/png.js index 8546cf4f..478bc144 100644 --- a/tests/png.js +++ b/tests/png.js @@ -66,8 +66,8 @@ const expected = { // browsers do (here a wrong sequence number in the second fcTL) { const frames = [100, 70000, 100].map((duration, i) => { - const frame = new Frame(8, 8, {duration}); - frame.fill(0xff0000ff + i * 0x1000); + const frame = new Frame(new Image(8, 8), {duration}); + frame.image.fill(0xff0000ff + i * 0x1000); return frame; }); const encoded = Buffer.from(await new Animation(frames).encodeAPNG({optimize: false})); diff --git a/tests/regressions.js b/tests/regressions.js index 54acb383..afa9aa68 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -82,7 +82,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // frames keep their metadata { - const frame = new Frame(10, 10, {duration: 500, xOffset: 2, yOffset: 4, disposalMode: 'background'}); + const frame = new Frame(new Image(10, 10), {duration: 500, xOffset: 2, yOffset: 4, disposalMode: 'background'}); const clone = frame.clone(); assert(clone instanceof Frame && clone.duration === 500 && clone.xOffset === 2 && clone.disposalMode === 'background', 'Frame.clone lost metadata'); @@ -92,7 +92,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // the frames are the array: the animation's frames have the array methods, the animation itself none { - const gif = new Animation([new Frame(4, 4), new Frame(4, 4), new Frame(4, 4)], {loopCount: 2}); + const gif = new Animation([new Frame(new Image(4, 4)), new Frame(new Image(4, 4)), new Frame(new Image(4, 4))], {loopCount: 2}); assert(new Animation(gif.frames.slice(1), {loopCount: gif.loopCount}).length === 2, 'frames.slice(start)'); assert(gif.frames.slice(-1).length === 1, 'frames.slice(negative)'); assert(gif.frames.map(frame => frame.width).join() === '4,4,4', 'frames.map'); @@ -101,7 +101,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // resizing GIFs scales partial frames proportionally { - const gif = new Animation([new Frame(10, 10), new Frame(4, 4, {duration: 100, xOffset: 6, yOffset: 6})]).resize(5, 5); + const gif = new Animation([new Frame(new Image(10, 10)), new Frame(new Image(4, 4), {duration: 100, xOffset: 6, yOffset: 6})]).resize(5, 5); assert(gif.width === 5 && gif.height === 5, `GIF.resize: canvas is ${gif.width}x${gif.height}`); assert(gif.frames[1].width === 2 && gif.frames[1].xOffset === 3, 'GIF.resize: partial frame not scaled'); } @@ -302,7 +302,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // gif frame durations are rounded to centiseconds { // (different frames: identical ones are merged) - const gif = new Animation([new Frame(2, 2, {duration: 16}), new Frame(2, 2, {duration: 14}).fill(0xff0000ff)]); + const gif = new Animation([new Frame(new Image(2, 2), {duration: 16}), new Frame(new Image(2, 2).fill(0xff0000ff), {duration: 14})]); const decoded = await Animation.decode(await gif.encodeGIF()); assert(decoded.frames[0].duration === 20 && decoded.frames[1].duration === 10, `gif durations should round, got ${decoded.frames[0].duration}, ${decoded.frames[1].duration}`); } @@ -342,13 +342,13 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // disposal modes read back as the names they are set with { - const frame = new Frame(1, 1, {duration: 100, xOffset: 0, yOffset: 0, disposalMode: 'previous'}); + const frame = new Frame(new Image(1, 1), {duration: 100, xOffset: 0, yOffset: 0, disposalMode: 'previous'}); assert(frame.disposalMode === 'previous', 'disposalMode should be a name, got ' + frame.disposalMode); // the numeric ids (0-3) are not accepted any more, the names are let threw = false; try { frame.disposalMode = 0; } catch (e) { threw = e instanceof RangeError; } assert(threw && frame.disposalMode === 'previous', 'disposalMode should only take names'); - const decoded = await Animation.decode(await new Animation([new Frame(2, 2, {duration: 100, xOffset: 0, yOffset: 0, disposalMode: 'background'}), new Frame(2, 2).fill(0xff0000ff)]).encodeGIF()); + const decoded = await Animation.decode(await new Animation([new Frame(new Image(2, 2), {duration: 100, xOffset: 0, yOffset: 0, disposalMode: 'background'}), new Frame(new Image(2, 2).fill(0xff0000ff))]).encodeGIF()); assert(decoded.length === 2, 'gif with disposal modes should roundtrip'); } @@ -382,15 +382,15 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); try { photo.clone().resize({}); } catch (e) { threw = e instanceof TypeError; } assert(threw, 'resize({}) throws'); - const frame = Frame.from(photo.clone(), {duration: 100, xOffset: 4, yOffset: 2}); + const frame = new Frame(photo.clone(), {duration: 100, xOffset: 4, yOffset: 2}); frame.resize({width: 6}); assert(frame.width === 6 && frame.height === 4 && frame.xOffset === 2 && frame.yOffset === 1, `Frame.resize({width}): ${frame.width}x${frame.height}+${frame.xOffset}+${frame.yOffset}`); - const animation = new Animation([Frame.from(photo.clone(), {duration: 100}), Frame.from(photo.clone().invert(), {duration: 100})]); - const other = new Animation([Frame.from(photo.clone(), {duration: 100}), Frame.from(photo.clone().invert(), {duration: 100})]); + const animation = new Animation([new Frame(photo.clone(), {duration: 100}), new Frame(photo.clone().invert(), {duration: 100})]); + const other = new Animation([new Frame(photo.clone(), {duration: 100}), new Frame(photo.clone().invert(), {duration: 100})]); animation.resize({height: 4}); other.resize(6, 4); - assert(animation.width === 6 && animation.height === 4 && [...animation].every((f, i) => same(f, other.frames[i])), 'Animation.resize({height})'); + assert(animation.width === 6 && animation.height === 4 && [...animation].every((f, i) => same(f.image, other.frames[i].image)), 'Animation.resize({height})'); } // coordinates() and pixels() yield every pixel row by row @@ -418,7 +418,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); // the encoders take the options of the encode functions await withFrozenTime(async () => { const {Animation} = require('..'); - const frames = () => [Frame.from(new Image(6, 6).fill(0x336699ff), {duration: 100}), Frame.from(new Image(6, 6).fill(0x996633ff), {duration: 100})]; + const frames = () => [new Frame(new Image(6, 6).fill(0x336699ff), {duration: 100}), new Frame(new Image(6, 6).fill(0x996633ff), {duration: 100})]; for (const [format, options] of [['GIF', {quality: 80, optimize: false}], ['APNG', {compression: 9}], ['WebP', {quality: 70, effort: 1}]]) { const animation = new Animation(frames(), {loopCount: 3}); const encoder = Animation[`${format.toLowerCase()}Encoder`](6, 6, {...options, loopCount: 3}); @@ -431,7 +431,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); await withFrozenTime(async () => { const {Animation} = require('..'); const still = new Image(6, 6).fill(0x336699ff); - const frames = () => [Frame.from(still.clone(), {duration: 100}), Frame.from(still.clone().invert(), {duration: 100})]; + const frames = () => [new Frame(still.clone(), {duration: 100}), new Frame(still.clone().invert(), {duration: 100})]; const forever = new Animation(frames(), {loopCount: Infinity}); assert(forever.loopCount === Infinity && new Animation(frames(), {loopCount: Infinity}).loopCount === Infinity, 'Animation keeps Infinity'); for (const format of ['GIF', 'APNG', 'WebP']) { @@ -458,7 +458,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const fs = require('fs'); const pixel = (image, x, y) => Array.from(image.bitmap.subarray((y * image.width + x) * 4, (y * image.width + x) * 4 + 4)); const tiff = orientation => Buffer.from([0x4d, 0x4d, 0, 42, 0, 0, 0, 8, 0, 1, 0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, orientation, 0, 0, 0, 0, 0, 0, 0, 0]); - const source = () => [0, 1].map(i => Frame.from(new Image(3, 2).fill((x, y) => Colors.fromRGBA(x * 100, y * 100, i * 120, 255)), {duration: 100})); + const source = () => [0, 1].map(i => new Frame(new Image(3, 2).fill((x, y) => Colors.fromRGBA(x * 100, y * 100, i * 120, 255)), {duration: 100})); // APNG (eXIf) and animated WebP (EXIF): orientation 6 turns the top left into the top right const png = Buffer.from(await new Animation(source(), {loopCount: 2}).encodeAPNG({metadata: {software: ''}})); @@ -489,7 +489,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const oriented = await Animation.decode(data, options); assert(oriented.width === 2 && oriented.height === 3 && oriented.length === 2, `${name}: rotated (${JSON.stringify(options)})`); for (let i = 0; i < 2; i++) - assert(pixel(oriented.frames[i], 1, 0).join() === pixel(stored.frames[i], 0, 0).join() && pixel(oriented.frames[i], 0, 2).join() === pixel(stored.frames[i], 2, 1).join(), `${name}: frame ${i} not oriented like Image.decode`); + assert(pixel(oriented.frames[i].image, 1, 0).join() === pixel(stored.frames[i].image, 0, 0).join() && pixel(oriented.frames[i].image, 0, 2).join() === pixel(stored.frames[i].image, 2, 1).join(), `${name}: frame ${i} not oriented like Image.decode`); } } // (without an orientation tag nothing changes) @@ -521,9 +521,9 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const plain = await Animation.decode(container, {gainMap: false}); const withMap = await Animation.decode(container); assert(plain.length > 1 && withMap.length === plain.length, 'JPEG XL animation with a jhgm box decodes'); - assert(plain.frames.every(f => f.gainMap === null) && (await Animation.decode(codestream)).frames.every(f => f.gainMap === null), 'JPEG XL: gainMap false, or no jhgm box'); - assert(withMap.frames.every(f => f.gainMap && f.gainMap.width === 4 && f.gainMap.max[0] === 2), 'JPEG XL: every frame has the gain map'); - assert(withMap.frames[0].gainMap !== withMap.frames[1].gainMap && withMap.frames.every((f, i) => Buffer.from(f.bitmap).equals(Buffer.from(plain.frames[i].bitmap))), 'JPEG XL: the frames are the same with the map, with maps of their own'); + assert(plain.frames.every(f => f.image.gainMap === null) && (await Animation.decode(codestream)).frames.every(f => f.image.gainMap === null), 'JPEG XL: gainMap false, or no jhgm box'); + assert(withMap.frames.every(f => f.image.gainMap && f.image.gainMap.width === 4 && f.image.gainMap.max[0] === 2), 'JPEG XL: every frame has the gain map'); + assert(withMap.frames[0].image.gainMap !== withMap.frames[1].image.gainMap && withMap.frames.every((f, i) => Buffer.from(f.image.bitmap).equals(Buffer.from(plain.frames[i].image.bitmap))), 'JPEG XL: the frames are the same with the map, with maps of their own'); // toneMapping is checked let threw = false; @@ -538,7 +538,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const bordered = () => new Image(6, 5).fill(0x102030ff).setPixelAt(2, 2, 0xffffffff).setPixelAt(3, 2, 0x00000000); const same = (a, b) => a.width === b.width && a.height === b.height && Buffer.from(a.bitmap).equals(Buffer.from(b.bitmap)); assert(same(bordered().trim(), bordered().trim(undefined)) && same(bordered().trim(), bordered().trim({})), 'trim() without arguments'); - const frame = () => Frame.from(bordered(), {duration: 100, xOffset: 3, yOffset: 4}); + const frame = () => new Frame(bordered(), {duration: 100, xOffset: 3, yOffset: 4}); assert(frame().trim({color: 0x102030ff}).xOffset === 5, 'Frame.trim({color}) moves the offsets'); assert(same(bordered().colorToAlpha(), bordered().colorToAlpha(undefined)) && same(bordered().colorToAlpha(), bordered().colorToAlpha({})), 'colorToAlpha() without arguments'); const map = new GainMap(new Image(4, 4).fill(0x808080ff), {min: 0, max: 2}); @@ -636,13 +636,6 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const shadowed = translucent.clone().dropShadow({color: 0x000000ff}); assert(px(shadowed, 2, 2).join() === px(translucent, 2, 2).join() || px(shadowed, 2, 2)[3] >= 128, 'dropShadow keeps translucent pixels over their shadow'); - // frames stay in place: the offsets move with the growth, up and left only as far as they allow - const frame = Frame.from(square(), {duration: 40, xOffset: 2, yOffset: 5}).dropShadow({blur: 2}); - assert(frame.xOffset === 0 && frame.yOffset === 0 && frame.width === 10 + 2 + 6 && frame.height === 10 + 5 + 6 && frame.duration === 40, `Frame.dropShadow: ${frame.width}x${frame.height} at ${frame.xOffset},${frame.yOffset}`); - assert(px(frame, 2 + 4, 5 + 4).join() === '255,128,64,255', 'Frame.dropShadow: the pixels stay where they were on the animation canvas'); - const farFrame = Frame.from(square(), {xOffset: 20, yOffset: 20}).dropShadow({blur: 1}); - assert(farFrame.xOffset === 17 && farFrame.yOffset === 17, 'Frame.dropShadow with room moves the offsets by the growth'); - // gain maps grow with the image const hdr = square(); hdr.gainMap = new GainMap(new Image(5, 5).fill(0xc0c0c0ff), {min: 0, max: 2}); @@ -663,8 +656,6 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); assert(alphaAt(holey.clone().outline({width: 1, color: 0x000000ff}), 1 + 4, 1 + 4) === 255, 'outline fills small holes of the shape'); throws(() => square().outline({width: -1}), RangeError, 'negative outline width'); throws(() => square().outline({width: Infinity}), RangeError, 'infinite outline width'); - const outlinedFrame = Frame.from(square(), {xOffset: 1, yOffset: 4}).outline({width: 3}); - assert(outlinedFrame.xOffset === 0 && outlinedFrame.yOffset === 1 && outlinedFrame.width === 10 + 1 + 3 && outlinedFrame.height === 10 + 3 + 3, `Frame.outline: ${outlinedFrame.width}x${outlinedFrame.height} at ${outlinedFrame.xOffset},${outlinedFrame.yOffset}`); const glowOnText = new Image(12, 12).fill((x, y) => (x - 5.5) ** 2 + (y - 5.5) ** 2 < 16 ? 0xffffffff : 0).outline({width: 2, color: 0xfacc15ff}).dropShadow({blur: 1, y: 2}); assert(glowOnText.width > 16 && glowOnText.height > 18, 'outline and dropShadow chain'); } @@ -1008,7 +999,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); } { // NaN and the infinities throw instead of drawing black, transparent or nothing; numeric strings still work - const {Frame, Animation} = require('..'); + const {Image, Frame, Animation} = require('..'); const image = () => new Image(8, 8).fill(0xff0000ff); const calls = { rgbaToColor: v => Colors.fromRGBA(v, 0, 0, 255), hslaToColor: v => Colors.fromHSLA(v, 0.5, 0.5, 1), colorToRGBA: v => Colors.toRGBA(v), @@ -1018,7 +1009,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); composite: v => image().composite(image(), {x: v, y: 0}), hueShift: v => image().hueShift(v), brightness: v => image().brightness(v), gamma: v => image().gamma(v), colorToAlpha: v => image().colorToAlpha({color: v}), rotate: v => image().rotate(v, {expand: false}), gradient: v => Colors.gradient({0: v, 1: 0}), roundCorners: v => image().roundCorners({radius: v}), fisheye: v => image().fisheye({strength: v}), encodePNG: v => image().encodePNG({compression: v}), encodeJPEG: v => image().encodeJPEG({quality: v}), - frame: v => new Frame(4, 4, {duration: v}), animation: v => new Animation([new Frame(4, 4)], {loopCount: v}), + frame: v => new Frame(new Image(4, 4), {duration: v}), animation: v => new Animation([new Frame(new Image(4, 4))], {loopCount: v}), }; for (const [name, call] of Object.entries(calls)) for (const value of [NaN, Infinity, -Infinity]) { diff --git a/tests/svg-images.js b/tests/svg-images.js index 34acf301..a8816230 100644 --- a/tests/svg-images.js +++ b/tests/svg-images.js @@ -50,7 +50,7 @@ const crc32 = bytes => { const png = await logo.encodePNG({compression: 1}); const webp = await logo.encodeWebP(); const jpeg = await opaque.encodeJPEG({quality: 90}); - const gif = await new Animation([Frame.from(opaque)]).encodeGIF(); + const gif = await new Animation([new Frame(opaque.clone())]).encodeGIF(); const otherPNG = await other.encodePNG({compression: 1}); // images given by href render exactly like the same images as data URIs, in every format and placement diff --git a/tests/svg.js b/tests/svg.js index 02974382..1e4f3d9c 100644 --- a/tests/svg.js +++ b/tests/svg.js @@ -41,7 +41,7 @@ const panic = message => { { // images embedded as data URIs: placed at their size, the pixels are the decoded image's - const {Animation, Frame} = require('..'); + const {Animation, Image, Frame} = require('..'); const pixel = (x, y, alpha) => Colors.fromRGBA(x * 10, y * 15, 128, alpha && x > 20 ? 0 : 255); const source = new Image(24, 16), opaque = new Image(24, 16); for (let y = 0; y < 16; y++) @@ -50,12 +50,12 @@ const panic = message => { opaque.setPixelAt(x, y, pixel(x, y, false)); } const rect = ''; - const gif = await new Animation([Frame.from(opaque)]).encodeGIF(); + const gif = await new Animation([new Frame(opaque.clone())]).encodeGIF(); const jpeg = await opaque.encodeJPEG({quality: 90}); const cases = [ ['image/png', await source.encodePNG({compression: 1}), source], ['image/webp', await source.encodeWebP(), source], - ['image/gif', gif, (await Animation.decode(gif)).frames[0]], + ['image/gif', gif, (await Animation.decode(gif)).frames[0].image], ['image/jpeg', jpeg, await Image.decode(jpeg)], ['image/svg+xml', require('zlib').gzipSync(rect), await Image.renderSVG(rect)] ]; diff --git a/tests/transform.js b/tests/transform.js index 39dad584..fc393cb0 100644 --- a/tests/transform.js +++ b/tests/transform.js @@ -106,12 +106,6 @@ const photo = (width, height) => new Image(width, height).fill((x, y) => Colors. throwsA(() => photo(5, 5).transform([1, 0, 0, 1, 0, 0], {origin: {x: NaN}}), RangeError, 'transform with a NaN origin'); throwsA(() => photo(5, 5).transform([1, 0, 0, 1, 0, 0], null), TypeError, 'transform with null options'); - // a frame stays where it is: it grows up and left as far as its offset allows - const frame = Frame.from(photo(20, 20), {duration: 40, xOffset: 3, yOffset: 50}).transform([Math.cos(Math.PI / 4), Math.sin(Math.PI / 4), -Math.sin(Math.PI / 4), Math.cos(Math.PI / 4), 0, 0]); - check(frame instanceof Frame && frame.duration === 40 && frame.xOffset === 0 && frame.yOffset >= 45 && frame.yOffset < 50, `Frame.transform: offsets ${frame.xOffset},${frame.yOffset}`); - const middle = Frame.from(photo(20, 20), {xOffset: 100, yOffset: 100}).transform([1, 0, 0.5, 1, 0, 0]); - check(middle.width === 30 && middle.xOffset === 95 && middle.yOffset === 100, `Frame.transform with room grows left, its pixels stay where they were: ${middle.width}, offsets ${middle.xOffset},${middle.yOffset}`); - // gain maps move with the image const hdr = photo(20, 10); hdr.gainMap = new GainMap(new Image(10, 5).fill((x) => x < 5 ? 0x000000ff : 0xffffffff), {min: 0, max: 2}); diff --git a/tests/trim.js b/tests/trim.js index 517c6889..92e5d811 100644 --- a/tests/trim.js +++ b/tests/trim.js @@ -42,8 +42,8 @@ const size = image => `${image.width}x${image.height}`; // frames keep their position { - const frame = new Frame(10, 10, {duration: 100, xOffset: 5, yOffset: 7}); - frame.composite(new Image(4, 4).fill(0xffffffff), {x: 2, y: 3}); + const frame = new Frame(new Image(10, 10), {duration: 100, xOffset: 5, yOffset: 7}); + frame.image.composite(new Image(4, 4).fill(0xffffffff), {x: 2, y: 3}); frame.trim(); if (size(frame) !== '4x4' || frame.xOffset !== 7 || frame.yOffset !== 10) panic(`trim: frame ${size(frame)} at ${frame.xOffset},${frame.yOffset}`); } diff --git a/tests/util/without-native.js b/tests/util/without-native.js index a51b2a5e..fdac2255 100644 --- a/tests/util/without-native.js +++ b/tests/util/without-native.js @@ -29,8 +29,8 @@ if (mode === 'plain') { for (const file of fs.readdirSync('./tests/pngsuite').filter(f => f.endsWith('.png'))) // also the damaged ones, which have to fail the same way results[file] = await Image.decode(fs.readFileSync(`./tests/pngsuite/${file}`)).then(image => sha256(image.bitmap), error => error.message); - const animation = await Animation.decode(await new Animation([Frame.from(image.clone().resize(32, 32)), Frame.from(image.clone().resize(32, 32).invert())]).encodeAPNG()); - results.apng = sha256(animation.frames[1].bitmap); + const animation = await Animation.decode(await new Animation([new Frame(image.clone().resize(32, 32)), new Frame(image.clone().resize(32, 32).invert())]).encodeAPNG()); + results.apng = sha256(animation.frames[1].image.bitmap); console.log(JSON.stringify(results)); })().catch(error => { console.error(error); diff --git a/tests/webp-lossy.js b/tests/webp-lossy.js index eef3cb43..40053148 100644 --- a/tests/webp-lossy.js +++ b/tests/webp-lossy.js @@ -142,7 +142,7 @@ const alphaReferences = { frame.composite(new Image(10, 10).fill(0xffffffff), {x: i * 10, y: 5}); return frame; }); - const animation = new Animation(frames.map(f => Frame.from(f, {duration: 100})), {loopCount: 0}); + const animation = new Animation(frames.map(f => new Frame(f.clone(), {duration: 100})), {loopCount: 0}); const lossy = await animation.encodeWebP({quality: 80}); const decoded = await Animation.decode(lossy); if (decoded.length !== 3) panic(`lossy animation: ${decoded.length} frames instead of 3`); @@ -150,14 +150,14 @@ const alphaReferences = { // (the last frame is half transparent: it's blended over the one before) const lossless = await Animation.decode(await animation.encodeWebP()); for (let f = 0; f < 3; f++) { - const [r, g, b, a] = Colors.toRGBA(decoded.frames[f].getPixelAt(1, 1)); - const [er, eg, eb, ea] = Colors.toRGBA(lossless.frames[f].getPixelAt(1, 1)); + const [r, g, b, a] = Colors.toRGBA(decoded.frames[f].image.getPixelAt(1, 1)); + const [er, eg, eb, ea] = Colors.toRGBA(lossless.frames[f].image.getPixelAt(1, 1)); if (Math.abs(r - er) + Math.abs(g - eg) + Math.abs(b - eb) > 12 || a !== ea) panic(`lossy animation: frame ${f} decodes to ${[r, g, b, a]}`); } if (!await animation.encodeWebP({quality: 200}).then(() => false, e => e instanceof RangeError)) panic('animation quality 200 should throw'); // alphaQuality for animations, also encoded a frame at a time - const faded = new Animation([0, 1].map(i => Frame.from(new Image(40, 30).fill((x, y) => Colors.fromRGBA(200, 50 * i, 9, x * 6 + y)), {duration: 100})), {loopCount: 0}); + const faded = new Animation([0, 1].map(i => new Frame(new Image(40, 30).fill((x, y) => Colors.fromRGBA(200, 50 * i, 9, x * 6 + y)), {duration: 100})), {loopCount: 0}); // (the same creation time: it's written in seconds, two encodes can be a second apart) const metadata = {creationTime: 0}; const fadedWebp = await faded.encodeWebP({quality: 80, alphaQuality: 0, optimize: false, metadata}); @@ -166,7 +166,7 @@ const alphaReferences = { if (sha256(await encoder.finish()) !== sha256(fadedWebp)) panic('alphaQuality: the incremental encoder writes another file'); for (const frame of await Animation.decode(fadedWebp)) { const levels = new Set(); - for (let i = 3; i < frame.bitmap.length; i += 4) levels.add(frame.bitmap[i]); + for (let i = 3; i < frame.image.bitmap.length; i += 4) levels.add(frame.image.bitmap[i]); if (levels.size > 2) panic(`alphaQuality 0: ${levels.size} alpha levels instead of 2`); } if (!await faded.encodeWebP({quality: 80, alphaQuality: 101}).then(() => false, e => e instanceof RangeError)) panic('animation alphaQuality 101 should throw'); diff --git a/tests/webp.js b/tests/webp.js index 586b7abc..511cbb17 100644 --- a/tests/webp.js +++ b/tests/webp.js @@ -46,7 +46,7 @@ const animated = [ if (animation.length !== 4 || animation.loopCount !== Infinity) panic('animated WebP: wrong frame or loop count'); if (animation.frames.map(f => f.duration).join() !== '80,120,80,200') panic('animated WebP: wrong durations'); animation.frames.forEach((frame, i) => { - if (sha256(frame.bitmap) !== animated[i]) panic(`animated WebP: frame ${i} doesn't match libwebp`); + if (sha256(frame.image.bitmap) !== animated[i]) panic(`animated WebP: frame ${i} doesn't match libwebp`); }); // filtered alpha (horizontal, vertical, gradient), from libwebp-test-data @@ -66,7 +66,7 @@ const animated = [ ]; if (blended.length !== 4) panic('blended WebP: wrong frame count'); blended.frames.forEach((frame, i) => { - if (sha256(frame.bitmap) !== blendedHashes[i]) panic(`blended WebP: frame ${i} doesn't match libwebp`); + if (sha256(frame.image.bitmap) !== blendedHashes[i]) panic(`blended WebP: frame ${i} doesn't match libwebp`); }); // lossless encoding: photo-like content (predictor), few colors (palette), alpha, odd sizes From 8ff3f515b519131389572c4d7215af1e5a4c2701 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 22:03:40 +0000 Subject: [PATCH 062/188] rename region to extract --- src/animation.ts | 2 +- src/image.ts | 4 ++-- tests/api-shapes.js | 2 +- tests/crop.js | 20 ++++++++++---------- tests/gainmap.js | 2 +- tests/readme.js | 2 +- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/animation.ts b/src/animation.ts index 3b9115b7..66522794 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -324,7 +324,7 @@ export class Animation { const right = Math.min(width, x + frame.width), bottom = Math.min(height, y + frame.height); const options = { duration: frame.duration, disposalMode: frame.disposalMode }; if (right <= left || bottom <= top) return new Frame(new Image(1, 1), { ...options, disposalMode: 'keep' }); - const clipped = frame.image.region(left - x, top - y, right - left, bottom - top); + const clipped = frame.image.extract(left - x, top - y, right - left, bottom - top); clipped.gainMap = null; return new Frame(clipped, { ...options, xOffset: left, yOffset: top }); }); diff --git a/src/image.ts b/src/image.ts index 6458ce51..3cc03ed2 100644 --- a/src/image.ts +++ b/src/image.ts @@ -1103,7 +1103,7 @@ export class Image { } /** - * Copies a region of the image into a new image, this one stays unchanged (like {@link Image.crop} on a clone, + * Copies a region of the image into a new image and leaves the image unchanged (like {@link Image.crop} on a clone, * without copying the whole image). The region is clipped to the image, so the result can be smaller than the * requested size * @param x The x offset (0-based) @@ -1111,7 +1111,7 @@ export class Image { * @param width The region's width * @param height The region's height */ - region(x: number, y: number, width: number, height: number): Image { + extract(x: number, y: number, width: number, height: number): Image { const bounds = this.__crop_bounds__(x, y, width, height); const image = this.__crop__(...bounds); this.__crop_gain_map__(image, ...bounds); diff --git a/tests/api-shapes.js b/tests/api-shapes.js index 00c05c65..2561b1d0 100644 --- a/tests/api-shapes.js +++ b/tests/api-shapes.js @@ -241,6 +241,6 @@ const throws = (call, type, message) => { { const gray = new Image(1, 1).fill(0xff0000ff).grayscale(); assert(gray.getPixelAt(0, 0) === 0x4c4c4cff, `grayscale: ${gray.getPixelAt(0, 0).toString(16)}`); - assert(new Image(1, 1).greyscale === undefined && new Image(1, 1).drawBox === undefined && new Image(1, 1).fit === undefined && new Image(1, 1).getRGBAAt === undefined, 'removed methods'); + assert(new Image(1, 1).greyscale === undefined && new Image(1, 1).drawBox === undefined && new Image(1, 1).fit === undefined && new Image(1, 1).getRGBAAt === undefined && new Image(1, 1).region === undefined, 'removed methods'); } })(); diff --git a/tests/crop.js b/tests/crop.js index 26d28cad..faa44384 100644 --- a/tests/crop.js +++ b/tests/crop.js @@ -15,25 +15,25 @@ const samePixels = require('./util/pixels'); const target = await fs.readFile('./tests/targets/crop.png'); if (!await samePixels(target, encoded)) process.exit(1); - // region: the same pixels as a new image, the source unchanged; clipped like crop + // extract: the same pixels as a new image, the source unchanged; clipped like crop const panic = message => { console.error(message); process.exit(1); }; const source = new Image(512, 512).fill((x, y) => Colors.fromHSL((x + y) / 1024 * 360, 1, .5)); const before = Buffer.from(source.bitmap); - const region = source.region(128, 128, 128, 128); - if (!await samePixels(target, await region.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}))) panic('region: wrong pixels'); - if (!Buffer.from(source.bitmap).equals(before)) panic('region: changed the source'); + const region = source.extract(128, 128, 128, 128); + if (!await samePixels(target, await region.encodePNG({compression: 1, metadata: {creationTime: 0, software: ''}}))) panic('extract: wrong pixels'); + if (!Buffer.from(source.bitmap).equals(before)) panic('extract: changed the source'); region.fill(0); - if (!Buffer.from(source.bitmap).equals(before)) panic('region: shares memory with the source'); + if (!Buffer.from(source.bitmap).equals(before)) panic('extract: shares memory with the source'); - const clipped = source.region(-10, 500, 30, 30); - if (clipped.width !== 20 || clipped.height !== 12) panic(`region: clipped to ${clipped.width}x${clipped.height} instead of 20x12`); - if (clipped.getPixelAt(1, 1) !== source.getPixelAt(1, 501)) panic('region: clipped region has wrong pixels'); + const clipped = source.extract(-10, 500, 30, 30); + if (clipped.width !== 20 || clipped.height !== 12) panic(`extract: clipped to ${clipped.width}x${clipped.height} instead of 20x12`); + if (clipped.getPixelAt(1, 1) !== source.getPixelAt(1, 501)) panic('extract: clipped region has wrong pixels'); let threw = false; - try { source.region(600, 0, 10, 10); } catch (err) { threw = err instanceof RangeError; } - if (!threw) panic('region: a region outside the image should throw a RangeError'); + try { source.extract(600, 0, 10, 10); } catch (err) { threw = err instanceof RangeError; } + if (!threw) panic('extract: a region outside the image should throw a RangeError'); // a frame's crop moves its offsets, so the pixels stay where they are in the animation const frame = new Frame(source.clone(), {duration: 100, xOffset: 4, yOffset: 6}); diff --git a/tests/gainmap.js b/tests/gainmap.js index 022e3733..6c5bcfbb 100644 --- a/tests/gainmap.js +++ b/tests/gainmap.js @@ -127,7 +127,7 @@ const meanValue = (image, left, top, right, bottom) => { const quarter = await Image.decode(await multi.encodeJPEG({gainMap: {scale: 0.25}})); quarter.crop(100, 40, 200, 160).resize(100, 80); if (quarter.gainMap.width !== 25 || quarter.gainMap.height !== 20) panic(`crop + resize: map ${quarter.gainMap.width}x${quarter.gainMap.height}`); - const region = multi.region(10, 20, 100, 50); + const region = multi.extract(10, 20, 100, 50); if (region.gainMap.width !== 100 || multi.gainMap.width !== 512) panic('region: the map'); } diff --git a/tests/readme.js b/tests/readme.js index 3e9e7945..a8dfec12 100644 --- a/tests/readme.js +++ b/tests/readme.js @@ -31,7 +31,7 @@ const font = name => fs.readFileSync('./tests/fonts/' + name); const card = {x: 40, y: 40, width: 520, height: 520, radius: 28}; const shadow = new Image(card.width + 80, card.height + 80).composite(new Image(card.width, card.height).fill(0x00000060), {x: 40, y: 48}).blur(16); image.composite(shadow, {x: card.x - 40, y: card.y - 40}); - const glass = image.region(card.x, card.y, card.width, card.height).blur(24) + const glass = image.extract(card.x, card.y, card.width, card.height).blur(24) .composite(new Image(card.width, card.height).fill(0xffffffff), {opacity: 0.18}) .roundCorners({radius: card.radius}); image.composite(glass, {x: card.x, y: card.y}); From 652fa1f9329884eafa39fabbc823324e5ee8a191 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 22:08:07 +0000 Subject: [PATCH 063/188] fix typos in the API docs --- src/colors.ts | 2 +- src/image.ts | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/colors.ts b/src/colors.ts index 8eec10b9..d38704e7 100644 --- a/src/colors.ts +++ b/src/colors.ts @@ -170,7 +170,7 @@ export class Colors { } /** - * Converts a color value to an array of RGB values (ignoring the colors alpha) + * Converts a color value to an array of RGB values (ignoring the color's alpha) * @param color The color value to convert * @returns The RGB values ([R, G, B]) */ diff --git a/src/image.ts b/src/image.ts index 3cc03ed2..3f030255 100644 --- a/src/image.ts +++ b/src/image.ts @@ -156,7 +156,7 @@ export interface QuantizeOptions { ditherStrength?: number; } -/** How {@link Image.transform} samples the pixels between the source's */ +/** How {@link Image.transform} and {@link Image.rotate} sample the pixels between the source's */ export type TransformMode = 'nearest-neighbor' | 'bilinear' | 'bicubic'; // the sampling modes of Image.transform() @@ -724,14 +724,14 @@ export class Image { } /** - * The images width + * The image's width */ get width(): number { return this.__width__; } /** - * The images height + * The image's height */ get height(): number { return this.__height__; @@ -1080,8 +1080,8 @@ export class Image { * The region is clipped to the image, so the result can be smaller than the requested size * @param x The x offset (0-based) * @param y The y offset (0-based) - * @param width The new images width - * @param height The new images height + * @param width The new width + * @param height The new height */ crop(x: number, y: number, width: number, height: number): this { const bounds = this.__crop_bounds__(x, y, width, height); @@ -1537,7 +1537,7 @@ export class Image { } /** - * Inverts the images colors + * Inverts the image's colors */ invert(): this { if (this.__gain_map__) return this.__unboost__(image => image.invert()); @@ -1552,7 +1552,7 @@ export class Image { } /** - * Inverts the images lightness (HSL), keeping hue and saturation: dark colors become light and vice versa + * Inverts the image's lightness (HSL), keeping hue and saturation: dark colors become light and vice versa */ invertLightness(): this { if (this.__gain_map__) return this.__unboost__(image => image.invertLightness()); @@ -1560,7 +1560,7 @@ export class Image { } /** - * Inverts the images value (HSV), keeping hue and saturation: the brightest channel `v` becomes `255 - v` + * Inverts the image's value (HSV), keeping hue and saturation: the brightest channel `v` becomes `255 - v` * and the others scale along, black becomes white */ invertValue(): this { @@ -1584,7 +1584,7 @@ export class Image { } /** - * Shifts the images hue + * Shifts the image's hue * @param degrees How many degrees to shift the hue by */ hueShift(degrees: number): this { @@ -2273,7 +2273,7 @@ export class Image { } /** - * Rounds the images corners, with antialiased edges + * Rounds the image's corners, with antialiased edges * @param options See {@link RoundCornersOptions} */ roundCorners(options?: RoundCornersOptions): this; @@ -2426,7 +2426,7 @@ export class Image { /** * Encodes the image as run length encoded TGA (32 bit, or 24 bit if the image is opaque) - * @param options See {@link EncodeOptions} + * @param options See {@link TGAEncodeOptions} */ async encodeTGA(options: TGAEncodeOptions = {}): Promise> { checkOptions(options, 'image.encodeTGA({ metadata })'); @@ -2659,7 +2659,7 @@ export class Image { } /** - * Creates a new image from the given SVG + * Creates a new image from the given SVG. * Supports shapes, paths, transforms, use/symbol, CSS styles, gradients, patterns, strokes, markers, clip paths, * masks, opacity, blend modes, filters (all primitives and the filter functions) and images embedded as data URIs * (PNG, JPEG, GIF, WebP, SVG) or given with {@link SVGOptions.images}, and text with the given or embedded fonts From 71daff89951a0bad462f791d382788a17d772ff5 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 22:08:07 +0000 Subject: [PATCH 064/188] update the changelog and migration guide --- CHANGELOG.md | 48 +++++++----- guides/Migrating to 4.0.md | 146 ++++++++++++++++++++++++++----------- 2 files changed, 132 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 496b4659..2b6514ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,12 @@ ### Breaking changes -Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation hints, then switch: the [migration guide](https://imagescript.dev/documents/Migrating_to_4.0.html) lists every change and the three that don't show up as deprecations. +Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation hints, then switch: the [migration guide](https://imagescript.dev/documents/Migrating_to_4.0.html) lists every change and the seven behavior changes that don't show up as deprecations or compile errors. -- **Methods that take options refuse anything else than an object**: a stray number, string, boolean or `null` as the options of `cropCircle()`, `Image.renderText()`, the encoders (`encodeWebP()`, `encodeAVIF()`, `encodeJXL()`, ...), `quantize()`, `transform()`, `dropShadow()`, `outline()`, the gradients, `Image.decode()`, the animation encoders, `new GainMap()` and `new TextLayout()` throws a `TypeError` that names the working form (e.g. `cropCircle({ size: 'cover' })`), for the 3.x calls that are gone like `cropCircle(true)` or `Image.renderText(font, 32, 'Hi')` instead of ignoring it. Only the type of the argument is checked: a key that isn't an option is still ignored, so a typo like `{ maxWith: 300 }` isn't caught. -- **The deprecated resize, SVG and frame disposal constants are removed**: `Image.RESIZE_NEAREST_NEIGHBOR`, `Image.RESIZE_BILINEAR`, `Image.RESIZE_BICUBIC`, `Image.RESIZE_AREA`, `Image.SVG_MODE_SCALE`, `Image.SVG_MODE_WIDTH`, `Image.SVG_MODE_HEIGHT` and `Frame.DISPOSAL_*` are gone: use `ResizeModes`, `SVGScaleModes` and `FrameDisposalModes` instead. `ResizeModes.AUTO` (and `Image.RESIZE_AUTO`) is removed too: pass only the width or the height, `image.resize({ width: 256 })`. The old values are no longer accepted either: `'RESIZE_BILINEAR'`, the numbers 1-3 for SVG scale modes and the numeric disposal ids 0-3. +- **Methods that take options refuse anything else than an object**: a stray number, string, boolean or `null` as the options of `cropCircle()`, `Image.renderText()`, the encoders (`encodeWebP()`, `encodeAVIF()`, `encodeJXL()`, ...), `quantize()`, `transform()`, `dropShadow()`, `outline()`, the gradients, `Image.decode()`, the animation encoders, and `new GainMap()` throws a `TypeError` that names the working form (e.g. `cropCircle({ size: 'cover' })`), so the 3.x calls that are gone like `cropCircle(true)` or `Image.renderText(font, 32, 'Hi')` fail instead of being ignored. Only the type of the argument is checked: a key that isn't an option is still ignored, so a typo like `{ maxWith: 300 }` isn't caught. +- **The mode constants are removed, modes are plain strings**: `Image.RESIZE_*`, `Image.SVG_MODE_*`, `Frame.DISPOSAL_*` and the `ResizeModes`, `SVGScaleModes` and `FrameDisposalModes` classes are gone. Resize modes are `'nearest-neighbor'`, `'bilinear'`, `'bicubic'` and `'area'` (the `ResizeMode` type), disposal modes `'any'`, `'keep'`, `'previous'` and `'background'` (`FrameDisposalModeName`), and the SVG modes became options of `renderSVG()` (below). `Image.RESIZE_AUTO`/`ResizeModes.AUTO` is `image.resize({ width: 256 })`. The old values are no longer accepted either: `'RESIZE_BILINEAR'`, the numbers 1-3 for SVG scale modes and the numeric disposal ids 0-3. -- **`absolute` is removed from `opacity()`, `red()`, `green()`, `blue()`, `lightness()` and `saturation()`**: they only scale now, `image.red(0.5)` (and `AdjustOptions`, the type of their options, is removed). For a fixed color, use `image.recolor()`. +- **`absolute` is removed from `opacity()`, `red()`, `green()`, `blue()`, `lightness()` and `saturation()`**: they only scale now, `image.red(0.5)` (and `AdjustOptions`, the type of their options, is removed). For a fixed color, use `image.recolor()`. In JavaScript a leftover `image.red(0.5, true)` scales the channel instead of setting it. - **`Image.encode()` is removed**: use `image.encodePNG()`, which takes the same arguments. @@ -21,41 +21,39 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **The `max` argument of `cropCircle()` is removed**: `cropCircle(true, feathering)` and `cropCircle({ max: true })` are now `cropCircle({ size: 'cover', feathering })`, and `cropCircle(false)` is `cropCircle()`, the default `size: 'contain'`. -- **The positional forms of `Image.renderText()` are removed**: `Image.renderText(font, size, text, color?, layout?, style?)` and `Image.renderText(font, size, text, options?)` are now `Image.renderText(text, { font, size, color, layout, ...style })`, which takes the same options. A `TextLayout` instance goes to `layout`. +- **The positional forms of `Image.renderText()` are removed**: `Image.renderText(font, size, text, color?, layout?, style?)` and `Image.renderText(font, size, text, options?)` are now `Image.renderText(text, { font, size, color, ...layout, ...style })`. -- **The deprecated animation encoder option types and `AnimationEncoder.frames` are removed**: `AnimationEncoderOptions`, `WebPAnimationEncoderOptions`, `GIFAnimationEncoderOptions` and `APNGAnimationEncoderOptions` are `AnimationEncodeOptions`, `WebPAnimationEncodeOptions`, `GIFEncodeOptions` and `APNGEncodeOptions`, each with `AnimationOptions` for the `loopCount`, and `encoder.frames` is `encoder.frameCount`. +- **The deprecated animation encoder option types and `AnimationEncoder.frames` are removed**: `AnimationEncoderOptions`, `WebPAnimationEncoderOptions`, `GIFAnimationEncoderOptions` and `APNGAnimationEncoderOptions` are `AnimationEncodeOptions`, `WebPAnimationEncodeOptions`, `GIFAnimationEncodeOptions` and `APNGEncodeOptions`, each with `AnimationOptions` for the `loopCount`, and `encoder.frames` is `encoder.frameCount`. -- **The positional mode of `scale()`, `resize()`, `contain()`, `fit()` and `cover()` is removed**, on `Image`, `Frame` and `Animation`: `image.resize(width, height, 'area')` and `image.resize(width, height, ResizeModes.AREA)` are now `image.resize(width, height, { mode: 'area' })`, and `image.scale(0.5, { mode })` takes the mode in the same way. Other values in the position throw a `TypeError`, and `fast` goes in the same object. +- **The positional mode of `scale()`, `resize()`, `contain()` and `cover()` is removed**, on `Image`, `Frame` and `Animation`: `image.resize(width, height, 'area')` and `image.resize(width, height, ResizeModes.AREA)` are now `image.resize(width, height, { mode: 'area' })`, and `image.scale(0.5, { mode })` takes the mode in the same way. Other values in the position throw a `TypeError`, and `fast` goes in the same object. -- **The positional arguments of `composite()`, `rotate()`, `sharpen()`, `dominantColor()`, `roundCorners()`, `fisheye()`, `trim()` and `colorToAlpha()` are removed**: the values go in the options object, which is the only second argument now. `image.composite(logo, 10, 20, { opacity: 0.5 })` is `image.composite(logo, { x: 10, y: 20, opacity: 0.5 })`, `image.sharpen(2, 3)` is `image.sharpen({ amount: 2, radius: 3 })`, `image.rotate(45, false)` is `image.rotate(45, { resize: false })`, `image.dominantColor(true, true, 15)` is `image.dominantColor({ ignoreBlack: true, ignoreWhite: true, threshold: 15 })`, `image.roundCorners(8)` is `image.roundCorners({ radius: 8 })` and `image.fisheye(3)` is `image.fisheye({ strength: 3 })`. `trim(color, options)` and `colorToAlpha(color, options)` take the color as `color` in the options (`trim({ color, tolerance })`, `colorToAlpha({ color, transparencyThreshold })`), and `Frame.trim()` works the same way. A value in the old position throws a `TypeError`. +- **The positional arguments of `composite()`, `rotate()`, `sharpen()`, `dominantColor()`, `roundCorners()`, `fisheye()`, `trim()` and `colorToAlpha()` are removed**: the values go in the options object, which is the only second argument now. `image.composite(logo, 10, 20, { opacity: 0.5 })` is `image.composite(logo, { x: 10, y: 20, opacity: 0.5 })`, `image.sharpen(2, 3)` is `image.sharpen({ amount: 2, radius: 3 })`, `image.rotate(45, false)` is `image.rotate(-45, { expand: false })` (see the clockwise rotation below), `image.dominantColor(true, true, 15)` is `image.dominantColor({ ignoreBlack: true, ignoreWhite: true, threshold: 15 })`, `image.roundCorners(8)` is `image.roundCorners({ radius: 8 })` and `image.fisheye(3)` is `image.fisheye({ strength: 3 })`. `trim(color, options)` and `colorToAlpha(color, options)` take the color as `color` in the options (`trim({ color, tolerance })`, `colorToAlpha({ color, transparencyThreshold })`), and `Frame.trim()` works the same way. A value in the old position throws a `TypeError`. -- **The positional arguments of the encoders are removed**: `encodePNG(compression, metadata)` and `encodePNG(metadata)` are `encodePNG({ compression, metadata })`, `encodeJPEG(quality)` is `encodeJPEG({ quality })`, `Animation.encodeGIF(quality)` is `encodeGIF({ quality })` and `Animation.encodeAPNG(compression)` is `encodeAPNG({ compression })`, and `GainMap.encode(quality)` is `encode({ quality })`. For example, `image.encodePNG(1, { author })` is `image.encodePNG({ compression: 1, metadata: { author } })`, and the metadata alone, `image.encodePNG({ author })`, throws a `TypeError` naming `{ metadata }`, because it would be ignored otherwise. +- **The positional arguments of the encoders are removed**: `encodePNG(compression, metadata)` and `encodePNG(metadata)` are `encodePNG({ compression, metadata })`, `encodeJPEG(quality)` is `encodeJPEG({ quality })`, `Animation.encodeGIF(quality)` is `encodeGIF({ colors })` (see the GIF options below) and `Animation.encodeAPNG(compression)` is `encodeAPNG({ compression })`, and `GainMap.encode(quality)` is `encode({ quality })`. For example, `image.encodePNG(1, { author })` is `image.encodePNG({ compression: 1, metadata: { author } })`; the metadata alone, `image.encodePNG({ author })`, is ignored now. -- **The positional arguments of `Image.renderSVG()` are removed**: `Image.renderSVG(svg, size, mode)` is `Image.renderSVG(svg, { size, mode })`, which takes the same `fonts` and `images` as before: `Image.renderSVG(svg, 2, 'width')` is now `Image.renderSVG(svg, { size: 2, mode: 'width' })`. +- **The positional arguments of `Image.renderSVG()` are removed**: `Image.renderSVG(svg, 512, 'width')` is `Image.renderSVG(svg, { width: 512 })` (see below), next to the `fonts` and `images` options. -- **The positional arguments of `new Frame()`, `Frame.from()` and `new Animation()` are removed, and `Animation.decode()` takes no flag**: `new Frame(width, height, duration, xOffset, yOffset, disposalMode)` is `new Frame(width, height, { duration, xOffset, yOffset, disposalMode })`, `Frame.from(image, duration, xOffset, yOffset, disposalMode)` is `Frame.from(image, { duration, xOffset, yOffset, disposalMode })`, `new Animation(frames, loopCount)` is `new Animation(frames, { loopCount })`, and `Animation.decode(data, true)` is `Animation.decode(data)`, or `Image.decode(data)` for the first frame only. +- **The positional arguments of `new Frame()`, `Frame.from()` and `new Animation()` are removed, and `Animation.decode()` takes no flag**: `new Frame(width, height, duration, xOffset, yOffset, disposalMode)` is `new Frame(new Image(width, height), { duration, xOffset, yOffset, disposalMode })`, `Frame.from(image, duration, xOffset, yOffset, disposalMode)` is `new Frame(image.clone(), { duration, xOffset, yOffset, disposalMode })` (see the frames below), `new Animation(frames, loopCount)` is `new Animation(frames, { loopCount })`, and `Animation.decode(data, true)` is `Animation.decode(data)`, or `Image.decode(data)` for the first frame only. - **`Image.decode()` returns the first frame of an animation**: a GIF or an animated WebP, which threw in 3.x, is an `Image` of its first frame, composed onto the canvas as `Animation.decode()` composes it, and decoding stops after that frame, so it is fast on long animations. Animated JPEG XLs and AVIF sequences were already their first frame. An APNG is still its default image, what viewers without APNG support show. Use `Animation.decode()` for all the frames. - **`decode()` returns an `Animation` only for files with several frames**: a GIF, APNG, animated WebP or JPEG XL with more than one frame is an `Animation` (`decode(gif)` used to be a `GIF`), and one with a single frame is an `Image`, like a still image (a one-frame GIF used to be a `GIF`). For the first frame alone, `Image.decode()` returns an `Image`. -- **The `GIF` class is removed**: `new GIF(frames, loopCount)` is `new Animation(frames, { loopCount })`, `GIF.decode(data)` is `Animation.decode(data)` and `gif.encode(quality)` is `animation.encodeGIF({ quality })`. `Animation` decodes and encodes GIFs the same way it does APNGs and WebPs. +- **The `GIF` class is removed**: `new GIF(frames, loopCount)` is `new Animation(frames, { loopCount })`, `GIF.decode(data)` is `Animation.decode(data)` and `gif.encode(quality)` is `animation.encodeGIF({ colors })`. `Animation` decodes and encodes GIFs the same way it does APNGs and WebPs. - **`Animation` holds its frames instead of extending `Array`**: `animation.frames` is the array the animation uses, and `animation[0]`, `animation.push(frame)`, `animation.map(...)` and the other array methods are `animation.frames[0]`, `animation.frames.push(frame)`, `animation.frames.map(...)`, and so on. `animation.slice(start, end)` is `new Animation(animation.frames.slice(start, end), { loopCount: animation.loopCount })`. `for (const frame of animation)`, `animation.length` and the methods of `Animation` work as before, and `Array.isArray(animation)` is `false`. - **Loop counts are `Infinity` (forever, the default) or an integer from 0**: `loopCount: -1` is `loopCount: Infinity`, and the encoders and the decoded animations use `Infinity` where they used `-1`. `-1` now throws a `RangeError` like the other negative numbers, `NaN` and fractions do, and numeric strings, `null` and booleans throw a `TypeError`. The check applies to `new Animation(frames, { loopCount })`, `animation.loopCount = ...` (which leaves the count as it was when it throws) and the encoders (`Animation.gifEncoder()`, `webpEncoder()` and `apngEncoder()`). Finite counts are the same as before: `loopCount: 2` still plays three times. -- **Text alignments only take the values of their own axis**: `TextLayout`'s `horizontalAlign` takes `'left'`, `'center'` or `'right'` and `verticalAlign` takes `'top'`, `'middle'` or `'bottom'`. 3.x accepted the other axis' values in either field, which now throws a `TypeError`: `new TextLayout({ verticalAlign: 'right' })` is `new TextLayout({ horizontalAlign: 'right' })`. +- **Text alignments only take the values of their own axis**: `horizontalAlign` takes `'left'`, `'center'` or `'right'` and `verticalAlign` takes `'top'`, `'middle'` or `'bottom'`. 3.x accepted the other axis' values in either field, which now throws a `TypeError`: `verticalAlign: 'right'` is `horizontalAlign: 'right'`. - **`decode()` takes options only**: `decode(data, true)` is `Image.decode(data)` for the first frame, and a boolean throws a `TypeError` that names the options form. -- **`opacity()`, `red()`, `green()`, `blue()`, `lightness()` and `saturation()` reject a second argument**: 3.x's `absolute` call like `image.red(0.5, true)` would silently scale by 0.5 now, so it throws a `TypeError` before the image is touched. Use `image.red(0.5)` to scale and `image.recolor()` for a fixed color. - -- **`resize()` needs a height in the positional form**: `image.resize(100)` throws a `TypeError` that names `resize({ width: 100 })`, which keeps the aspect ratio. The positional form takes both sides as finite numbers, `Image`, `Frame` and `Animation` alike. +- **`resize()` needs a height in the positional form**: `image.resize(100)` throws a `RangeError`; `resize({ width: 100 })` keeps the aspect ratio. The positional form takes both sides as finite numbers, `Image`, `Frame` and `Animation` alike. - **Loop counts a format can't store throw a `RangeError`**: a GIF stores up to 65535 loops, a WebP up to 65534 and an APNG up to 2147483646 (2^31 - 2). A larger count used to wrap around silently (65536 as a GIF was forever). The encoders and the streaming encoders refuse it before encoding; `Infinity` is always fine. -- **Removed and narrowed exported types**: `TextOptions` is `RenderTextOptions`, `FrameDisposalModeId` is removed (`FrameDisposalModeName` has the names), `AdjustOptions` is removed with `absolute`, `PNGMetadata` is `Metadata`, and `AnimationEncoderOptions`, `WebPAnimationEncoderOptions`, `GIFAnimationEncoderOptions` and `APNGAnimationEncoderOptions` are `AnimationEncodeOptions`, `WebPAnimationEncodeOptions`, `GIFEncodeOptions` and `APNGEncodeOptions`. `ResizeMode` and `SVGScaleMode` are narrowed to the lower-case names (`'area'`, `'scale'`, ...): the `RESIZE_*` strings and the numbers 1-3 are no longer part of the types. +- **Removed and narrowed exported types**: `TextOptions` is `RenderTextOptions`, `FrameDisposalModeId` is removed (`FrameDisposalModeName` has the names), `AdjustOptions` is removed with `absolute`, `PNGMetadata` is `Metadata`, and `AnimationEncoderOptions`, `WebPAnimationEncoderOptions`, `GIFAnimationEncoderOptions` and `APNGAnimationEncoderOptions` are `AnimationEncodeOptions`, `WebPAnimationEncodeOptions`, `GIFAnimationEncodeOptions` and `APNGEncodeOptions`. `ResizeMode` is narrowed to the lower-case names (`'area'`, ...), and `SVGScaleMode` is removed. - **`contain()` pads to the box, `fit()` is removed**: `image.contain(w, h)` resizes the image to fit inside the box and centers it on a canvas of exactly that size, like CSS' `object-fit: contain` (what `fit()` did), with transparent bars or a `background` color. The resize without bars, which `contain()` did, is `resize({ width, height, fit: 'inside' })`. @@ -65,9 +63,19 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **`renderSVG()` takes `width`, `height` or `scale`**: `{ size: 512, mode: 'width' }` is `{ width: 512 }`, `{ size: 2 }` is `{ scale: 2 }`; `SVGScaleModes` and `SVGScaleMode` are removed. More than one of them throws a `TypeError`. -- **Renamed**: `greyscale()` is `grayscale()` (American spelling throughout), the `resize` option of `rotate()` is `expand` (like `transform()`, `outline()` and `dropShadow()`), `GainMap`'s `gainMapMin` and `gainMapMax` (properties and options) are `min` and `max`, and the parameters of `setPixelAt()`, `lightness()` and `saturation()` are `color` and `factor`. +- **`rotate()` turns clockwise**, like CSS' `rotate()` (3.x turned counterclockwise): negate the angle for the old result. Its `fast` option is removed (the precise sampling is the default now) and `mode` (`'nearest-neighbor'`, `'bilinear'` or `'bicubic'`) samples like `transform()`. + +- **`encodeJPEG()` composites transparent pixels onto a background**, black by default (`background` sets the color), instead of dropping the alpha and showing the stored color. Opaque images encode as before. + +- **`Frame` holds an image instead of extending `Image`**: `frame.image` is the image, `new Frame(image, options)` takes it (without a copy), and `Frame.from()` is removed. `resize()`, `scale()`, `crop()` and `trim()` stay on the frame and move its offsets; everything else is `frame.image.blur(2)` and the like, and leaves the offsets alone (`transform()`, `dropShadow()` and `outline()` no longer move them). + +- **GIF palettes take a color count**: the `quality` of `Animation.encodeGIF()` and `Animation.gifEncoder()` is `colors` (2-256, default 256, which `quality` 90 and above gave), with the new `dither` and `ditherStrength`. Their options type is `GIFAnimationEncodeOptions`; `GIFEncodeOptions` is now the options of `Image.encodeGIF()`. + +- **Removed**: `getRGBAAt()` (use `Colors.toRGBA(image.getPixelAt(x, y))`). + +- **Renamed**: `greyscale()` is `grayscale()` (American spelling throughout), `region()` is `extract()`, the `resize` option of `rotate()` is `expand` (like `transform()`, `outline()` and `dropShadow()`), `GainMap`'s `gainMapMin` and `gainMapMax` (properties and options) are `min` and `max`, and the parameters of `setPixelAt()`, `lightness()` and `saturation()` are `color` and `factor`. -- **Fewer types and classes**: `TextLayout` isn't exported, `renderText()` takes the layout as a plain object (`layout: { maxWidth: 300 }`). `AnimationDecodeOptions` is `DecodeOptions`, which all decode functions take. `encodeTGA()` takes `TGAEncodeOptions`; `EncodeOptions` is the `metadata` that every encoder's options extend, and the `gainMap` option of the JPEG, AVIF and JPEG XL encoders is `boolean | GainMapEmbedOptions`. +- **Fewer types and classes**: `TextLayout` isn't exported, `renderText()` takes the layout options with its other options (`{ font, size, maxWidth: 300 }`). `AnimationDecodeOptions` is `DecodeOptions`, which all decode functions take. `encodeTGA()` takes `TGAEncodeOptions`; `EncodeOptions` is the `metadata` that every encoder's options extend, and the `gainMap` option of the JPEG, AVIF and JPEG XL encoders is `boolean | GainMapEmbedOptions`. - **Values are checked when they are set**: `frame.duration`, `frame.xOffset`, `frame.yOffset` and the parameters of a `GainMap` throw for invalid values (instead of failing in a later encode) and keep their value. @@ -81,6 +89,8 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - `resize({ width, height, fit: 'inside' })` on images, frames and animations: as large as fits inside the box, keeping the aspect ratio. - `encodeJPEG({ subsampling: '4:4:4' | '4:2:0' })`; the default stays 4:2:0 up to quality 90 and 4:4:4 above. - `Colors.gradient()` takes an array of evenly spaced colors, like the `stops` of the gradient functions. +- `Image.encodeGIF()` for single images, with `colors`, `dither` and `ditherStrength` (also on the animation encoders). +- `renderSVG({ fonts })` takes a single font without an array. ## 3.20.1 diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index ef0abed9..49175813 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -1,8 +1,8 @@ # Migrating to 4.0 -ImageScript 4.0 removes everything 3.x deprecated, and changes five behaviors. Most of the removals are mechanical: -the compiler points at them. The five behavior changes are not deprecations, nothing warns about them, and code can -keep compiling while it does something else. Read the first section even if your build is clean. +ImageScript 4.0 removes everything 3.x deprecated, reshapes parts of the API, and changes seven behaviors. Most of the +removals are mechanical: the compiler points at them. The seven behavior changes are not deprecations, nothing warns +about them, and code keeps compiling while it does something else. Read the first section even if your build is clean. Node.js 22.13 or newer is required, as it was for 3.x (`engines` in `package.json`). @@ -12,7 +12,8 @@ The recommended path: strikethrough, and the deprecation text names the replacement. `tsc` doesn't fail on them, so use the editor's problems list, or a lint rule for deprecated APIs (typescript-eslint has `no-deprecated`), to get the full list. 2. Fix every deprecation hint, using the tables below. -3. Switch to 4.0 and go through the five behavior changes. The [checklist](#checklist) at the end has grep patterns +3. Switch to 4.0, fix what the compiler reports with the [renamed and reshaped APIs](#renamed-and-reshaped-apis), and go + through the seven behavior changes. The [checklist](#checklist) at the end has grep patterns for finding the code they affect. ## Behavior changes @@ -37,7 +38,7 @@ import { Animation, decode } from 'imagescript'; const decoded = await decode(data); if (decoded instanceof Animation) { - const out = await decoded.encodeGIF({ quality: 90 }); + const out = await decoded.encodeGIF(); } ``` @@ -131,20 +132,42 @@ still works without an error, but gives a different color: `Colors.fromHSL(0.5, | `Colors.fromHSL(h, s, l)` with `h` in 0-1 | `Colors.fromHSL(h * 360, s, l)` | | `Colors.toHSLA(color)[0]` in 0-1 | `Colors.toHSLA(color)[0] / 360` for the old value | +### `rotate()` turns clockwise + +`image.rotate(angle)` turns the image clockwise, like CSS' `rotate()`. In 3.x it turned counterclockwise. Negate the +angle to keep the old result: + +| 3.x | 4.0 | +| --- | --- | +| `image.rotate(90)` | `image.rotate(-90)` (or `image.rotate(270)`) | +| `image.rotate(45, false)` | `image.rotate(-45, { expand: false })` | + +Search your code for: `.rotate(`. + +### JPEG composites transparent pixels onto black + +JPEG has no alpha channel. In 3.x, `encodeJPEG()` dropped the alpha and kept the color, so a fully transparent pixel +showed whatever RGB it had stored (often black, but not always). In 4.0, an image with transparent or translucent pixels +is composited onto a background first, black by default, the way a viewer would show it on black. Pass `background` +for another color: `image.encodeJPEG({ background: 0xffffffff })`. Opaque images encode as before, byte for byte. + +Search your code for: `encodeJPEG(` on images that can have transparency. + ## Removed APIs -Each table maps a 3.x API to its 4.0 replacement. The deprecation hint in 3.x gives the same mapping. The replacements -are documented on their own pages: {@link ResizeModes}, {@link FrameDisposalModes}, +Each table maps a 3.x API to its 4.0 replacement. The deprecation hint in 3.x names a replacement too, but some of those +replacements were reshaped in 4.0 as well (for example `ResizeModes`, which became plain strings); the tables give the +4.0 form. The replacements are documented on their own pages: {@link ResizeMode}, {@link FrameDisposalModeName}, {@link Colors}, {@link Frame}, {@link Animation}, {@link AnimationEncoder} and {@link Metadata}. ### Constants and modes | 3.x | 4.0 | | --- | --- | -| `Image.RESIZE_NEAREST_NEIGHBOR` | `ResizeModes.NEAREST_NEIGHBOR` (`'nearest-neighbor'`) | -| `Image.RESIZE_BILINEAR` | `ResizeModes.BILINEAR` (`'bilinear'`) | -| `Image.RESIZE_BICUBIC` | `ResizeModes.BICUBIC` (`'bicubic'`) | -| `Image.RESIZE_AREA` | `ResizeModes.AREA` (`'area'`) | +| `Image.RESIZE_NEAREST_NEIGHBOR`, `ResizeModes.NEAREST_NEIGHBOR` | `'nearest-neighbor'` | +| `Image.RESIZE_BILINEAR`, `ResizeModes.BILINEAR` | `'bilinear'` | +| `Image.RESIZE_BICUBIC`, `ResizeModes.BICUBIC` | `'bicubic'` | +| `Image.RESIZE_AREA`, `ResizeModes.AREA` | `'area'` | | `Image.RESIZE_AUTO`, `ResizeModes.AUTO` | Pass only the width or the height: `image.resize({ width: 256 })` | | The upper-case mode strings, `'RESIZE_BILINEAR'` and the others | The lower-case names, `'bilinear'` and the others | | The `ResizeMode` type, with the upper-case strings or the numbers 1-3 | Narrowed to the lower-case names: `'nearest-neighbor'`, `'bilinear'`, `'bicubic'` or `'area'` | @@ -152,10 +175,12 @@ are documented on their own pages: {@link ResizeModes}, {@link FrameDisposalMode | `Image.SVG_MODE_SCALE` (or `1`) | `Image.renderSVG(svg, { scale: 2 })`, see [renamed APIs](#renamed-and-reshaped-apis) | | `Image.SVG_MODE_WIDTH` (or `2`) | `Image.renderSVG(svg, { width: 512 })`, see [renamed APIs](#renamed-and-reshaped-apis) | | `Image.SVG_MODE_HEIGHT` (or `3`) | `Image.renderSVG(svg, { height: 512 })`, see [renamed APIs](#renamed-and-reshaped-apis) | -| `Frame.DISPOSAL_ANY`, `Frame.DISPOSAL_KEEP`, `Frame.DISPOSAL_PREVIOUS`, `Frame.DISPOSAL_BACKGROUND` | `FrameDisposalModes.ANY`, `.KEEP`, `.PREVIOUS`, `.BACKGROUND` | +| `Frame.DISPOSAL_ANY`, `Frame.DISPOSAL_KEEP`, `Frame.DISPOSAL_PREVIOUS`, `Frame.DISPOSAL_BACKGROUND`, and the same on `FrameDisposalModes` | `'any'`, `'keep'`, `'previous'`, `'background'` | | Numeric disposal ids: `frame.disposalMode = 3`, `disposalMode: 3` in the frame options | The names: `frame.disposalMode = 'background'`, `disposalMode: 'background'`. `FrameDisposalModeId` is removed | -The old string and number values are not accepted any more, so `image.resize(256, 256, 'RESIZE_BICUBIC')` throws. +The modes are plain strings in 4.0: the `ResizeModes` and `FrameDisposalModes` classes are removed, and the types +{@link ResizeMode} and {@link FrameDisposalModeName} list the names. The old string and number values are not accepted +any more, so `image.resize(256, 256, { mode: 'RESIZE_BICUBIC' })` throws. ### Colors, pixels and encoding on `Image` @@ -179,8 +204,7 @@ The color helpers take the same arguments and return the same values, so `Image. ### Channel methods and `absolute` `opacity()`, `red()`, `green()`, `blue()`, `lightness()` and `saturation()` only scale now. The `absolute` argument is -removed, and so is the `AdjustOptions` type. A second argument throws a `TypeError` before the image is touched, so -`image.red(0.5, true)` fails loudly instead of scaling. +removed, and so is the `AdjustOptions` type. | 3.x | 4.0 | | --- | --- | @@ -189,9 +213,9 @@ removed, and so is the `AdjustOptions` type. A second argument throws a `TypeErr | `AdjustOptions` (type) | Removed with `absolute`: the channel methods take the factor only | | `image.red(0.5, { absolute: true })` | See below | -The extra argument throws a `TypeError` in 4.0, so `image.red(0.5, true)` fails at the call, in JavaScript and in untyped -code, before the image is touched. (Had it been ignored, it would have scaled the channel: on a pixel with red 0 the -result stays 0, where 3.x's `absolute` set it to 128.) `tsc` reports the call as `Expected 1 arguments, but got 2`. +`tsc` reports a leftover call as `Expected 1 arguments, but got 2`. In JavaScript the extra argument is ignored, so +`image.red(0.5, true)` scales the channel instead of setting it (a pixel with red 0 stays 0, where 3.x's `absolute` set +it to 128): fix these while the 3.x deprecation hints still point at them. For a fixed color, use `image.recolor(color)`. It sets the RGB of every pixel and keeps each pixel's alpha. The 3.x `absolute` calls set one channel of every pixel to a value, and `recolor()` has no equivalent for a single channel. Set @@ -216,7 +240,7 @@ read the color with `Colors.toHSLA()`, set the lightness or saturation, and writ | --- | --- | | `new GIF(frames, loopCount)` | `new Animation(frames, { loopCount })` | | `GIF.decode(data)` | `Animation.decode(data)`, which decodes APNGs and WebPs too | -| `gif.encode(quality)` | `animation.encodeGIF({ quality })` | +| `gif.encode(quality)` | `animation.encodeGIF({ colors })`, see the [GIF options](#renamed-and-reshaped-apis) | | `GIF` (the class, also in `instanceof`) | `Animation`. `GIF` is no longer exported | | `animation.slice(start, end)` | `new Animation(animation.frames.slice(start, end), { loopCount: animation.loopCount })` | | `animation[i]` and the array methods | `animation.frames[i]` and `animation.frames.` | @@ -229,7 +253,7 @@ The option types of the animation encoders are renamed, and each one takes `loop | --- | --- | | `AnimationEncoderOptions` | `AnimationEncodeOptions & AnimationOptions` | | `WebPAnimationEncoderOptions` | `WebPAnimationEncodeOptions & AnimationOptions` | -| `GIFAnimationEncoderOptions` | `GIFEncodeOptions & AnimationOptions` | +| `GIFAnimationEncoderOptions` | `GIFAnimationEncodeOptions & AnimationOptions` | | `APNGAnimationEncoderOptions` | `APNGEncodeOptions & AnimationOptions` | ### Text, crop and metadata @@ -237,19 +261,19 @@ The option types of the animation encoders are renamed, and each one takes `loop | 3.x | 4.0 | | --- | --- | | `Image.renderText(font, size, text)` | `Image.renderText(text, { font, size })` | -| `Image.renderText(font, size, text, color, layout, style)` | `Image.renderText(text, { font, size, color, layout, ...style })` | +| `Image.renderText(font, size, text, color, layout, style)` | `Image.renderText(text, { font, size, color, ...layout, ...style })` | | `Image.renderText(font, size, text, options)` | `Image.renderText(text, { font, size, ...options })` | | `TextOptions` (type) | `RenderTextOptions` | -| `layout: { verticalAlign: 'right' }`, `horizontalAlign: 'bottom'` (the other axis' values) | `horizontalAlign` takes `'left'`, `'center'` or `'right'`, `verticalAlign` takes `'top'`, `'middle'` or `'bottom'`. Other values throw a `TypeError` | +| `verticalAlign: 'right'`, `horizontalAlign: 'bottom'` (the other axis' values) | `horizontalAlign` takes `'left'`, `'center'` or `'right'`, `verticalAlign` takes `'top'`, `'middle'` or `'bottom'`. Other values throw a `TypeError` | | `image.cropCircle(true)` | `image.cropCircle({ size: 'cover' })` | | `image.cropCircle(true, feathering)` | `image.cropCircle({ size: 'cover', feathering })` | | `image.cropCircle(false)` | `image.cropCircle()`, the default `size: 'contain'` | | `image.cropCircle({ max: true })` | `image.cropCircle({ size: 'cover' })` | | `PNGMetadata` (type) | `Metadata`, which all encoders take | -`renderText()` takes the text first. The font, the size, the color and the layout are all in the options object, and -`layout` takes the layout options (a plain object, see {@link TextLayoutOptions}). The options are documented in {@link RenderTextOptions}. -Crop options are in {@link CropCircleOptions}. A key that is not an option is ignored without an error, so +`renderText()` takes the text first. The font, the size, the color, the layout options ({@link TextLayoutOptions}) and +the style are all in the one options object, documented in {@link RenderTextOptions}. Crop options are in +{@link CropCircleOptions}. A key that is not an option is ignored without an error, so `cropCircle({ max: true })` quietly becomes the default `contain`: change it to `size: 'cover'`. ### Positional arguments @@ -259,18 +283,18 @@ object under the names of their options. The options of each method are on its o {@link ResizeOptions}, {@link CompositeOptions}, {@link RotateOptions}, {@link SharpenOptions}, {@link DominantColorOptions}, {@link RoundCornersOptions}, {@link FisheyeOptions}, {@link TrimOptions}, {@link ColorToAlphaOptions}, {@link PNGEncodeOptions}, {@link JPEGEncodeOptions}, {@link SVGOptions} and -{@link FrameOptions}. The animation options are {@link GIFEncodeOptions}, {@link APNGEncodeOptions}, +{@link FrameOptions}. The animation options are {@link GIFAnimationEncodeOptions}, {@link APNGEncodeOptions}, {@link DecodeOptions} and {@link GainMapEncodeOptions} for `GainMap.encode()`. | 3.x | 4.0 | | --- | --- | | `image.resize(256, 256, 'bicubic')`, `image.resize(256, 256, ResizeModes.AREA)` | `image.resize(256, 256, { mode: 'bicubic' })` | | `image.scale(0.5, 'area')`, `image.scale(0.5, ResizeModes.AREA)` | `image.scale(0.5, { mode: 'area' })` | -| `image.resize(100)` (width only, the height left out) | `image.resize({ width: 100 })`, which keeps the aspect ratio. The positional form throws a `TypeError` without a height | +| `image.resize(100)` (width only, the height left out) | `image.resize({ width: 100 })`, which keeps the aspect ratio. The positional form throws a `RangeError` without a height | | `image.contain(w, h, mode)`, `image.fit(w, h, mode)`, `image.cover(w, h, mode)` | `image.resize({ width: w, height: h, fit: 'inside', mode })`, `image.contain(w, h, { mode })` and `image.cover(w, h, { mode })`, see [`contain()` pads to the box](#contain-pads-to-the-box) | | `Frame` and `Animation`: `resize(w, h, mode)` | `resize(w, h, { mode })` | | `image.composite(logo, 10, 20, { opacity: 0.5 })` | `image.composite(logo, { x: 10, y: 20, opacity: 0.5 })` | -| `image.rotate(45, false)` | `image.rotate(45, { expand: false })` | +| `image.rotate(45, false)` | `image.rotate(-45, { expand: false })`: the angle is negated because [`rotate()` turns clockwise](#rotate-turns-clockwise) now | | `image.sharpen(2, 3)` | `image.sharpen({ amount: 2, radius: 3 })` | | `image.dominantColor(true, true, 15)` | `image.dominantColor({ ignoreBlack: true, ignoreWhite: true, threshold: 15 })` | | `image.roundCorners(8)` | `image.roundCorners({ radius: 8 })` | @@ -278,14 +302,14 @@ object under the names of their options. The options of each method are on its o | `image.trim(color, options)`, `frame.trim(color, options)` | `image.trim({ color, ...options })` | | `image.colorToAlpha(color, options)` | `image.colorToAlpha({ color, ...options })` | | `image.encodePNG(1, { author })` | `image.encodePNG({ compression: 1, metadata: { author } })` | -| `image.encodePNG({ author })` (metadata alone) | `image.encodePNG({ metadata: { author } })`. The old form throws, see below | +| `image.encodePNG({ author })` (metadata alone) | `image.encodePNG({ metadata: { author } })`. The old form is ignored, see below | | `image.encodeJPEG(90)` | `image.encodeJPEG({ quality: 90 })` | -| `animation.encodeGIF(90)` | `animation.encodeGIF({ quality: 90 })` | +| `animation.encodeGIF(90)` | `animation.encodeGIF()`; a quality of 90 and above was the default 256 colors, see the [GIF options](#renamed-and-reshaped-apis) for lower ones | | `animation.encodeAPNG(9)` | `animation.encodeAPNG({ compression: 9 })` | | `gainMap.encode(90)` | `gainMap.encode({ quality: 90 })` | | `Image.renderSVG(svg, 512, 'width')` | `Image.renderSVG(svg, { width: 512 })` (and `{ height }`, `{ scale }`) | -| `new Frame(w, h, duration, x, y, disposal)` | `new Frame(w, h, { duration, xOffset, yOffset, disposalMode })` | -| `Frame.from(image, duration, x, y, disposal)` | `Frame.from(image, { duration, xOffset, yOffset, disposalMode })` | +| `new Frame(w, h, duration, x, y, disposal)` | `new Frame(new Image(w, h), { duration, xOffset, yOffset, disposalMode })`, see [frames](#frames-hold-an-image) | +| `Frame.from(image, duration, x, y, disposal)` | `new Frame(image.clone(), { duration, xOffset, yOffset, disposalMode })` | | `new Animation(frames, loopCount)` | `new Animation(frames, { loopCount })` | | `Animation.decode(data, true)` | `Image.decode(data)` for the first frame, `Animation.decode(data)` for all | | `decode(data, true)` | `Image.decode(data)` | @@ -293,8 +317,8 @@ object under the names of their options. The options of each method are on its o `decode()` and `Animation.decode()` take options only: a boolean throws a `TypeError` that names the options form. -`encodePNG()` also throws when the metadata is passed without `metadata`. In 3.x, `image.encodePNG({ author })` was -read as metadata; in 4.0 it would be ignored, so it throws a `TypeError` that names `{ metadata }`. +In 3.x, `image.encodePNG({ author })` was read as metadata. In 4.0 the metadata goes in `metadata`, and an `author` key +at the top level is ignored like any other unknown key: TypeScript reports it for an inline object, JavaScript doesn't. ## Renamed and reshaped APIs @@ -302,21 +326,51 @@ These changed in 4.0 without a deprecation in 3.x. Most are compile errors in Ty options objects that aren't written inline, an old option key is ignored without an error (`rotate(45, { resize: false })` grows the image again), so search for them too. +### Methods and options + | 3.x | 4.0 | | --- | --- | -| `image.rotate(angle, { resize: false })` | `image.rotate(angle, { expand: false })`, like `transform()`, `outline()` and `dropShadow()` | +| `image.rotate(angle, { resize: false })` | `image.rotate(-angle, { expand: false })`, like `transform()`, `outline()` and `dropShadow()` (the angle negated, as [`rotate()` turns clockwise](#rotate-turns-clockwise)) | +| `image.rotate(angle, { fast: false })`, `{ fast: true }` | `image.rotate(-angle)`: the precise bilinear sampling is the default now, `fast` is removed. `mode` picks `'nearest-neighbor'`, `'bilinear'` or `'bicubic'`, like `transform()` | +| `ResizeModes.AREA` and the other `ResizeModes` | `'area'` and the other names, see [constants and modes](#constants-and-modes) | +| `FrameDisposalModes.BACKGROUND` and the other `FrameDisposalModes` | `'background'` and the other names | | `Image.renderSVG(svg, { size: 512, mode: 'width' })`, `{ mode: 'height' }` | `Image.renderSVG(svg, { width: 512 })`, `{ height: 512 }` | | `Image.renderSVG(svg, { size: 2 })`, `{ size: 2, mode: 'scale' }` | `Image.renderSVG(svg, { scale: 2 })` | | `SVGScaleModes`, `SVGScaleMode` | Removed, see the two rows above | | `image.drawBox(x, y, w, h, color)` | `image.composite(new Image(w, h).fill(color), { x, y })`. It blends a translucent color over the image; `drawBox()` replaced the pixels | | `image.drawCircle(x, y, r, color)` | `image.composite(new Image(2 * r, 2 * r).fill(color).cropCircle(), { x: x - r, y: y - r })` | | `image.greyscale()` | `image.grayscale()` (the whole API spells American English) | -| `new TextLayout(options)`, `layout: new TextLayout(options)` | `layout: options`, a plain object ({@link TextLayoutOptions}); the class isn't exported | +| `Image.renderText(text, { font, size, layout: { maxWidth: 300 } })`, `layout: new TextLayout(options)` | `Image.renderText(text, { font, size, maxWidth: 300 })`: the layout options ({@link TextLayoutOptions}) are part of the options; `TextLayout` isn't exported | +| `image.getRGBAAt(x, y)` | `Colors.toRGBA(image.getPixelAt(x, y))`, an array (`getRGBAAt()` returned a view into the bitmap) | +| `image.region(x, y, w, h)` | `image.extract(x, y, w, h)` | +| `animation.encodeGIF({ quality })`, `Animation.gifEncoder(w, h, { quality })` | `{ colors }`: the palette size, 2-256. A quality of 90 and above is the default `256`; below, `colors: Math.round(8 * 32 ** (quality / 90))` gives the old palette size. `dither` (`'floyd-steinberg'` by default, `'atkinson'`, `'ordered'` or `'none'`) and `ditherStrength` are new | +| `GIFEncodeOptions` (the options of `Animation.encodeGIF()`) | `GIFAnimationEncodeOptions`. `GIFEncodeOptions` is now the options of the new `Image.encodeGIF()` | | `AnimationDecodeOptions` (type) | `DecodeOptions`, which `Image.decode()`, `Animation.decode()` and `decode()` all take | | `EncodeOptions` (type) as the options of `encodeTGA()` | `TGAEncodeOptions`. `EncodeOptions` is now the `metadata` that all encoder options extend | | `new GainMap(image, { gainMapMin, gainMapMax })`, `map.gainMapMin`, `map.gainMapMax` | `new GainMap(image, { min, max })`, `map.min`, `map.max` | | `setPixelAt(x, y, pixelColor)`, `lightness(value)`, `saturation(value)` (parameter names only) | `setPixelAt(x, y, color)`, `lightness(factor)`, `saturation(factor)` | +### Frames hold an image + +A `Frame` is no longer an `Image`: it holds one, `frame.image`, with the frame's duration, offsets and disposal mode. +`new Frame()` takes the image (which the frame uses as it is, without a copy), and `Frame.from()` is removed. + +| 3.x | 4.0 | +| --- | --- | +| `new Frame(width, height, options)` | `new Frame(new Image(width, height), options)` | +| `Frame.from(image, options)` | `new Frame(image.clone(), options)`, or `new Frame(image, options)` without the copy | +| `frame.blur(2)`, `frame.fill(color)`, `frame.bitmap`, `frame.gainMap`, ... | `frame.image.blur(2)`, `frame.image.fill(color)`, `frame.image.bitmap`, `frame.image.gainMap`, ... | +| `image.composite(frame)`, `frame.encodePNG()` | `image.composite(frame.image)`, `frame.image.encodePNG()` | +| `frame instanceof Image` | `false` now | + +`resize()`, `scale()`, `crop()` and `trim()` stay on the frame and move its offsets so its pixels stay where they are in +the animation, and `width`, `height`, `clone()` and `toString()` work as before. Everything else goes through +`frame.image` and leaves the offsets alone. In 3.x, a frame that grew up or left from `transform()`, `dropShadow()` or +`outline()` moved its offsets with the growth (as far as they allowed); now the image grows and the offsets stay, so +move them yourself if the frame's pixels have to stay in place. + +### Checked values and the canvas + Values that were only checked when they were used are now checked when they are set: `frame.duration`, `frame.xOffset` and `frame.yOffset` (a finite number, a duration from 0), and the parameters of a `GainMap` (finite, 1 or 3 values per channel, a gamma above 0, `hdrCapacityMin` from 0, `useBaseColorSpace` a boolean). An invalid value @@ -339,9 +393,11 @@ That covers numbers, strings, booleans and `null`. The message names the form th TypeError: cropCircle() takes an options object, like cropCircle({ size: 'cover' }), not boolean ``` -This is how the positional calls you missed show up at runtime. It applies to every method that took options in 3.x, +This is how the positional calls you missed show up at runtime. It applies to every method that takes options, including `cropCircle()`, `Image.renderText()`, `quantize()`, `transform()`, `dropShadow()`, `outline()`, the -gradients, `Image.decode()`, the encoders, `Animation.decode()`, `new GainMap()` and the `layout` of `Image.renderText()`. +gradients, `Image.decode()`, the encoders, `Animation.decode()` and `new GainMap()`. Only the type of the argument is +checked: a key that isn't an option is ignored, so a typo like `{ maxWith: 300 }` is not caught at runtime (TypeScript +reports it for an inline object). ## Checklist @@ -350,7 +406,7 @@ Grep patterns for the affected code. They find candidates; read each hit, becaus ```sh # constants and modes -grep -rnE 'RESIZE_|SVG_MODE_|DISPOSAL_' src +grep -rnE 'RESIZE_|SVG_MODE_|DISPOSAL_|ResizeModes|FrameDisposalModes' src # encode() on images, and the color helpers grep -rnE '\.encode\(' src @@ -379,7 +435,11 @@ grep -rnE '\.(red|green|blue|opacity|lightness|saturation)\([^)]*,' src # renamed and reshaped in 4.0 grep -rnE '\.(contain|fit)\(|greyscale|drawBox|drawCircle|SVGScaleMode|TextLayout|AnimationDecodeOptions|gainMapM(in|ax)' src -grep -rnE 'fromHSLA?\(|toHSLA\(|rotate\([^)]*resize|renderSVG\([^)]*(size|mode)' src +grep -rnE 'fromHSLA?\(|toHSLA\(|\.rotate\(|renderSVG\([^)]*(size|mode)|layout:|getRGBAAt|\.region\(' src +grep -rnE 'encodeGIF\(|gifEncoder\(|GIFEncodeOptions|encodeJPEG\(' src + +# frames: anything an Image does, on a frame +grep -rnE 'new Frame\(|Frame\.from\(|frame\w*\.[a-zA-Z]+\(' src # GainMap grep -rnE 'gainMap\.encode\(|\.encode\(\s*[0-9]' src @@ -388,6 +448,6 @@ grep -rnE 'gainMap\.encode\(|\.encode\(\s*[0-9]' src Also search for the variables that hold animations, and for `[0]`, `.map(`, `.filter(` and `.forEach(` on them: the search can't tell an animation from an array, so check the type of each receiver. -Then compile with `tsc`, and run the tests that cover your decode and encode paths. The five behavior changes need -runtime checks: a file with several frames, a one-frame GIF, any `loopCount: -1`, every `contain()` and every HSL -hue in your code. +Then compile with `tsc`, and run the tests that cover your decode and encode paths. The seven behavior changes need +runtime checks: a file with several frames, a one-frame GIF, any `loopCount: -1`, every `contain()`, every HSL hue, +every `rotate()` and every JPEG made from an image with transparency. From 9bfa1522317de2d4dd208a2a47693daaedb241b4 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 22:26:43 +0000 Subject: [PATCH 065/188] dither quantize like the GIF encoders by default --- src/image.ts | 6 +++--- tests/palette.js | 10 ++++++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/image.ts b/src/image.ts index 3f030255..3bbf0eca 100644 --- a/src/image.ts +++ b/src/image.ts @@ -146,12 +146,12 @@ export interface QuantizeOptions { palette?: Color[]; /** * How the rounding error of each pixel is handled, which makes gradients look smooth with few colors, see - * {@link DitherMethod}. Default: `'none'` + * {@link DitherMethod}. Default: `'floyd-steinberg'`, like {@link GIFEncodeOptions.dither} */ dither?: DitherMethod; /** * How strongly to dither, from `0` (no spreading or threshold, the same as `'none'`) to `1` (the full error or - * threshold). Default: `1` + * threshold). Default: `0.85`, like {@link GIFEncodeOptions.ditherStrength} */ ditherStrength?: number; } @@ -1689,7 +1689,7 @@ export class Image { quantize(options: QuantizeOptions = {}): this { checkOptions(options, 'image.quantize({ colors: 16 })'); if (typeof options !== 'object' || options === null) throw new TypeError('quantize takes an options object'); - const { colors, palette, dither = 'none', ditherStrength = 1 } = options; + const { colors, palette, dither = 'floyd-steinberg', ditherStrength = 0.85 } = options; // everything is checked before the image changes if (colors !== undefined && palette !== undefined) throw new TypeError('quantize takes colors or palette, not both'); diff --git a/tests/palette.js b/tests/palette.js index ac7fe7a1..0f7a7c58 100644 --- a/tests/palette.js +++ b/tests/palette.js @@ -70,14 +70,20 @@ const blockError = (original, result) => { // dithering averages out: 8x8 block averages of a gradient with 4 colors are closer to the original than undithered const ramp = new Image(128, 16).fill(x => Colors.fromRGBA(Math.round(x * 255 / 127), Math.round(x * 255 / 127), Math.round(x * 255 / 127), 255)); - const plain = blockError(ramp, ramp.clone().quantize({colors: 4})); + const plain = blockError(ramp, ramp.clone().quantize({colors: 4, dither: 'none'})); for (const dither of ['floyd-steinberg', 'atkinson', 'ordered']) { const error = blockError(ramp, ramp.clone().quantize({colors: 4, dither})); if (!(error < plain)) panic(`${dither} block error ${error} is not below the undithered ${plain}`); } + // the defaults are the GIF encoders': Floyd-Steinberg at 0.85 + { + const byDefault = photo(30, 20).quantize({colors: 6}); + const explicit = photo(30, 20).quantize({colors: 6, dither: 'floyd-steinberg', ditherStrength: 0.85}); + if (!Buffer.from(byDefault.bitmap).equals(Buffer.from(explicit.bitmap))) panic('quantize defaults: not floyd-steinberg at 0.85'); + } // strength 0 is the same as no dithering for (const dither of ['floyd-steinberg', 'atkinson', 'ordered']) { - const none = photo(20, 20).quantize({colors: 4}); + const none = photo(20, 20).quantize({colors: 4, dither: 'none'}); const zero = photo(20, 20).quantize({colors: 4, dither, ditherStrength: 0}); if (!Buffer.from(none.bitmap).equals(Buffer.from(zero.bitmap))) panic(`${dither} with ditherStrength 0 differs from none`); } From 2180106e18238a12ba642c5a81e2aca8e5695d83 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 22:28:42 +0000 Subject: [PATCH 066/188] move frame offsets with every size-changing method and add them to Animation --- src/animation.ts | 116 ++++++++++++++++++++++++++++++++++++++--- src/frame.ts | 124 ++++++++++++++++++++++++++++++++------------ src/image.ts | 22 +++++++- tests/api-shapes.js | 56 ++++++++++++++++++++ 4 files changed, 278 insertions(+), 40 deletions(-) diff --git a/src/animation.ts b/src/animation.ts index 66522794..305701b8 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -6,7 +6,7 @@ import * as jxl from './codecs/jxl.ts'; import { availableThreads } from './util/threads.ts'; import { BinaryData, view } from './util/mem.ts'; import { GainMap } from './gainmap.ts'; -import { Image, DecodeOptions, EncodeOptions, GIFEncodeOptions, ContainOptions, ResizeOptions, ResizeToOptions, resizeArguments, insideSize } from './image.ts'; +import { Image, DecodeOptions, EncodeOptions, GIFEncodeOptions, ContainOptions, ResizeOptions, ResizeToOptions, RotateOptions, TransformOptions, ShadowOptions, OutlineOptions, TrimOptions, resizeArguments, insideSize } from './image.ts'; import { Frame } from './frame.ts'; import { ImageType } from './image-type.ts'; import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts'; @@ -283,16 +283,120 @@ export class Animation { } this.__canvas__ = [boxWidth, boxHeight]; if (backgroundColor !== 0) { - const composited = png.compositeAnimation({ width: boxWidth, height: boxHeight, frames: this.__apngFrames__() }); - this.frames = composited.map(({ pixels, duration }) => { - const content = new Image(boxWidth, boxHeight); - content.bitmap = pixels; - return new Frame(new Image(boxWidth, boxHeight).fill(backgroundColor).composite(content), { duration, disposalMode: 'background' }); + this.frames = this.__flattened__().map(frame => { + frame.image = new Image(boxWidth, boxHeight).fill(backgroundColor).composite(frame.image); + return frame; }); } return this; } + /** + * Scales the animation by a factor: every frame and its offsets, and the canvas + * @param factor The factor + * @param options See {@link ResizeOptions} + */ + scale(factor: number, options?: ResizeOptions): this { + if (!(finite(factor, 'factor') > 0)) throw new RangeError(`Invalid scale factor: ${factor}`); + return this.resize(this.width * factor, this.height * factor, options); + } + + /** + * Trims the canvas: removes the borders of one color around what any frame shows (in the composited animation), + * like {@link Image.trim}. The frames keep their pixels, the canvas is cropped like {@link Animation.crop} + * @param options The color (by default the top left pixel of the first frame) and the tolerance, see + * {@link TrimOptions} + */ + trim(options: TrimOptions = {}): this { + checkOptions(options, 'trim({ tolerance: 0.1 })'); + const frames = this.__flattened__(); + if (!frames.length) return this; + const { color = frames[0].image.getPixelAt(0, 0), ...rest } = options; + let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity; + for (const { image } of frames) { + const bounds = image.__trim_bounds__(color, rest); + if (!bounds) continue; + left = Math.min(left, bounds[0]); + top = Math.min(top, bounds[1]); + right = Math.max(right, bounds[0] + bounds[2]); + bottom = Math.max(bottom, bounds[1] + bounds[3]); + } + if (left === Infinity) return this; + return this.crop(left, top, right - left, bottom - top); + } + + /** + * Rotates the animation clockwise, like {@link Image.rotate}: the frames become full canvas frames of the + * composited animation (a partial frame turned by any angle wouldn't cover a rectangle anymore), each one rotated, + * and the canvas takes their size + * @param angle The angle in degrees, clockwise + * @param options See {@link RotateOptions} + */ + rotate(angle: number, options?: RotateOptions): this { + return this.__each__(image => image.rotate(angle, options)); + } + + /** + * Transforms the animation with an affine transform, like {@link Image.transform}: the frames become full canvas + * frames of the composited animation, each one transformed (by default around the canvas' middle), and the canvas + * takes their size + * @param matrix The six values [a, b, c, d, e, f] + * @param options See {@link TransformOptions} + */ + transform(matrix: ArrayLike, options?: TransformOptions): this { + return this.__each__(image => image.transform(matrix, options)); + } + + /** + * Adds a shadow behind what the animation shows, like {@link Image.dropShadow}: the frames become full canvas + * frames of the composited animation, each one with its shadow, and the canvas grows with them + * @param options See {@link ShadowOptions} + */ + dropShadow(options?: ShadowOptions): this { + return this.__each__(image => image.dropShadow(options)); + } + + /** + * Draws an outline around what the animation shows, like {@link Image.outline}: the frames become full canvas + * frames of the composited animation, each one outlined, and the canvas grows with them + * @param options See {@link OutlineOptions} + */ + outline(options?: OutlineOptions): this { + return this.__each__(image => image.outline(options)); + } + + /** + * The composited animation: one full canvas frame per frame, with its duration (gain maps are dropped) + * @internal + * @ignore + */ + __flattened__(): Frame[] { + const { width, height } = this; + if (!this.length) return []; + return png.compositeAnimation({ width, height, frames: this.__apngFrames__() }).map(({ pixels, duration }) => { + const image = new Image(width, height); + image.bitmap = pixels; + return new Frame(image, { duration, disposalMode: 'background' }); + }); + } + + /** + * Applies a change to every frame of the composited animation; the canvas takes the frames' new size. Nothing + * changes if the change throws + * @internal + * @ignore + */ + private __each__(change: (image: Image) => unknown): this { + const { width, height } = this; + if (!width || !height) throw new RangeError('An animation without frames or canvas can\'t be changed'); + const frames = this.__flattened__(); + const probe = frames[0]?.image ?? new Image(width, height); + for (const frame of frames.length ? frames : [new Frame(probe)]) change(frame.image); + this.frames = frames; + this.__canvas__ = [probe.width, probe.height]; + return this; + } + /** * Resizes the animation to cover the given box, keeping its aspect ratio, and crops the overflowing edges, like * {@link Image.cover}: the canvas gets exactly that size diff --git a/src/frame.ts b/src/frame.ts index 5ccf6f8b..d9708d50 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -1,4 +1,4 @@ -import { Image, ResizeOptions, ResizeToOptions, TrimOptions, resizeArguments } from './image.ts'; +import { Image, ResizeOptions, ResizeToOptions, TrimOptions, ContainOptions, RotateOptions, TransformOptions, ShadowOptions, OutlineOptions } from './image.ts'; import { checkOptions, finite } from './util/options.ts'; /** @@ -29,8 +29,10 @@ export interface FrameOptions { /** * A frame of an {@link Animation}: an {@link Image} ({@link Frame.image}) with a duration, offsets and a disposal mode. - * The frame's own methods (resize, scale, crop, trim) keep its pixels where they are in the animation by moving the - * offsets along; every other change goes to the image (`frame.image.blur(2)`), which leaves the offsets as they are + * The frame has the methods that change the image's size (resize, scale, contain, cover, crop, trim, rotate, transform, + * dropShadow, outline), which move the offsets along so the frame stays in place in the animation; every other change + * goes to the image (`frame.image.blur(2)`). The image's own size-changing methods leave the offsets as they are. + * Offsets can become negative: the parts of a frame outside the animation's canvas aren't encoded */ export class Frame { /** @internal @ignore */ @@ -177,6 +179,25 @@ export class Frame { return new Frame(this.__image__.clone(), { duration: this.duration, xOffset: this.xOffset, yOffset: this.yOffset, disposalMode: this.disposalMode }); } + /** + * Runs a change of the image, moving the offsets the way the image reports its pixels moved + * @internal + * @ignore + */ + private __tracked__(change: (image: Image) => unknown): this { + const image = this.__image__; + image.__moved__ = (scaleX, scaleY, x, y) => { + this.xOffset = Math.round(this.xOffset * scaleX + x); + this.yOffset = Math.round(this.yOffset * scaleY + y); + }; + try { + change(image); + } finally { + delete image.__moved__; + } + return this; + } + /** * Resizes the frame, scaling its offsets along with it (as if the whole animation was resized by the same factor) * @param width The new width @@ -191,15 +212,9 @@ export class Frame { */ resize(options: ResizeToOptions): this; resize(widthOrOptions: number | ResizeToOptions, heightArgument?: number, optionsArgument?: ResizeOptions): this { - const originalWidth = this.width, originalHeight = this.height; - if (typeof widthOrOptions === 'number') this.__image__.resize(widthOrOptions, heightArgument as number, optionsArgument); - else { - resizeArguments(widthOrOptions, heightArgument, optionsArgument); - this.__image__.resize(widthOrOptions); - } - this.xOffset = Math.round(this.xOffset * this.width / originalWidth); - this.yOffset = Math.round(this.yOffset * this.height / originalHeight); - return this; + return this.__tracked__(image => typeof widthOrOptions === 'number' + ? image.resize(widthOrOptions, heightArgument as number, optionsArgument) + : image.resize(widthOrOptions)); } /** @@ -208,11 +223,30 @@ export class Frame { * @param options See {@link ResizeOptions} */ scale(factor: number, options?: ResizeOptions): this { - const originalWidth = this.width, originalHeight = this.height; - this.__image__.scale(factor, options); - this.xOffset = Math.round(this.xOffset * this.width / originalWidth); - this.yOffset = Math.round(this.yOffset * this.height / originalHeight); - return this; + return this.__tracked__(image => image.scale(factor, options)); + } + + /** + * Resizes the frame to fit inside the given box and pads it to exactly that size, like {@link Image.contain}. The + * offsets scale with the image and then move by the padding, so the image stays where the scaled frame would be + * @param width The width of the box + * @param height The height of the box + * @param options The background and how to resize, see {@link ContainOptions} + */ + contain(width: number, height: number, options?: ContainOptions): this { + return this.__tracked__(image => image.contain(width, height, options)); + } + + /** + * Resizes the frame to cover the given box and crops the overflowing edges, like {@link Image.cover}. The offsets + * scale with the image and then move by the cropped amount, so the remaining pixels stay where the scaled frame + * had them + * @param width The width of the box + * @param height The height of the box + * @param options See {@link ResizeOptions} + */ + cover(width: number, height: number, options?: ResizeOptions): this { + return this.__tracked__(image => image.cover(width, height, options)); } /** @@ -224,26 +258,52 @@ export class Frame { * @param height The new height */ crop(x: number, y: number, width: number, height: number): this { - const left = Math.max(0, Math.floor(finite(x, 'x'))), top = Math.max(0, Math.floor(finite(y, 'y'))); - this.__image__.crop(x, y, width, height); - this.xOffset += left; - this.yOffset += top; - return this; + return this.__tracked__(image => image.crop(x, y, width, height)); } /** * Removes borders of one color, moving the offsets so the remaining pixels stay where they are in the animation * @param options See {@link TrimOptions} */ - trim(options?: TrimOptions): this; - trim(options: TrimOptions = {}): this { - checkOptions(options, 'trim({ tolerance: 0.1 })'); - const { color, ...rest } = options; - const bounds = this.__image__.__trim_bounds__(color, rest); - if (!bounds) return this; - this.__image__.crop(...bounds); - this.xOffset += bounds[0]; - this.yOffset += bounds[1]; - return this; + trim(options?: TrimOptions): this { + return this.__tracked__(image => image.trim(options)); + } + + /** + * Rotates the frame clockwise around its middle, like {@link Image.rotate}: when it grows (or shrinks), the + * offsets move so its middle stays where it is in the animation + * @param angle The angle in degrees, clockwise + * @param options See {@link RotateOptions} + */ + rotate(angle: number, options?: RotateOptions): this { + return this.__tracked__(image => image.rotate(angle, options)); + } + + /** + * Transforms the frame with an affine transform, like {@link Image.transform}: when it grows, the offsets move + * so the transform's origin stays where it is in the animation + * @param matrix The six values [a, b, c, d, e, f] + * @param options See {@link TransformOptions} + */ + transform(matrix: ArrayLike, options?: TransformOptions): this { + return this.__tracked__(image => image.transform(matrix, options)); + } + + /** + * Adds a shadow behind the frame's image, like {@link Image.dropShadow}: when it grows up or left, the offsets + * move so the image stays where it is in the animation + * @param options See {@link ShadowOptions} + */ + dropShadow(options?: ShadowOptions): this { + return this.__tracked__(image => image.dropShadow(options)); + } + + /** + * Draws an outline around the frame's shape, like {@link Image.outline}: when it grows up or left, the offsets + * move so the image stays where it is in the animation + * @param options See {@link OutlineOptions} + */ + outline(options?: OutlineOptions): this { + return this.__tracked__(image => image.outline(options)); } } diff --git a/src/image.ts b/src/image.ts index 3bbf0eca..99615058 100644 --- a/src/image.ts +++ b/src/image.ts @@ -906,6 +906,15 @@ export class Image { return image; } + /** + * Set by {@link Frame} while one of its methods runs: the methods that change the image's size call it with where + * the pixels went, so the frame can move its offsets. The new image's top left corner is where the point (x, y) + * of the old image was, after the old image was scaled by (scaleX, scaleY) + * @internal + * @ignore + */ + declare __moved__?: (scaleX: number, scaleY: number, x: number, y: number) => void; + /** * Resizes the image by the given factor * @param factor The factor to resize the image with @@ -917,6 +926,7 @@ export class Image { const { mode, fast } = resizeOptions(options); const image = this.__scale__(factor, mode, fast); this.__gain_map__?.__rescale__(this.width, this.height, image.width, image.height); + this.__moved__?.(image.width / this.width, image.height / this.height, 0, 0); return this.__apply__(image); } @@ -950,6 +960,7 @@ export class Image { const { mode, fast } = resizeOptions(options); const image = this.__resize__(...this.__resized_size__(width, height, fit), mode, fast); this.__gain_map__?.__rescale__(this.width, this.height, image.width, image.height); + this.__moved__?.(image.width / this.width, image.height / this.height, 0, 0); return this.__apply__(image); } @@ -980,6 +991,7 @@ export class Image { const x = (width - this.width) / 2 | 0, y = (height - this.height) / 2 | 0; result.composite(this, { x, y }); this.__gain_map__?.__transform__(this.width, this.height, result.width, result.height, (X, Y) => [X - x, Y - y]); + this.__moved__?.(1, 1, -x, -y); return this.__apply__(result); } @@ -1009,6 +1021,7 @@ export class Image { const region = { x: ~~((resizedWidth - width) / 2), y: ~~((resizedHeight - height) / 2), width, height }; const fx = resizedWidth / this.width, fy = resizedHeight / this.height; this.__gain_map__?.__transform__(this.width, this.height, width, height, (X, Y) => [(X + region.x) / fx, (Y + region.y) / fy], Math.min(fx, fy)); + this.__moved__?.(fx, fy, region.x, region.y); return this.__apply__(this.__resize__(resizedWidth, resizedHeight, resizeOpts.mode, resizeOpts.fast, region)); } this.resize(resizedWidth, resizedHeight, resizeOpts); @@ -1086,6 +1099,7 @@ export class Image { crop(x: number, y: number, width: number, height: number): this { const bounds = this.__crop_bounds__(x, y, width, height); this.__crop_gain_map__(this, ...bounds); + this.__moved__?.(1, 1, bounds[0], bounds[1]); return this.__apply__(this.__crop__(...bounds)); } @@ -1149,6 +1163,7 @@ export class Image { const bounds = this.__trim_bounds__(color, rest); if (!bounds) return this; this.__crop_gain_map__(this, ...bounds); + this.__moved__?.(1, 1, bounds[0], bounds[1]); return this.__apply__(this.__crop__(...bounds)); } @@ -1818,6 +1833,7 @@ export class Image { const x = X + left, y = Y + top; return [inverse[0] * x + inverse[2] * y + inverse[4], inverse[1] * x + inverse[3] * y + inverse[5]]; }, scale); + this.__moved__?.(1, 1, left, top); return this.__apply__(out); } @@ -1933,8 +1949,8 @@ export class Image { this.__gain_map__ = map; } map?.__transform__(this.width, this.height, width, height, (X, Y) => [X - left, Y - top]); - this.__apply__(result); - return this; + this.__moved__?.(1, 1, -left, -top); + return this.__apply__(result); } /** @@ -2170,6 +2186,7 @@ export class Image { } rotateGainMap(out.width, out.height); + this.__moved__?.(1, 1, (width - out.width) / 2, (height - out.height) / 2); return this.__apply__(out); } @@ -2183,6 +2200,7 @@ export class Image { const inverse: [number, number, number, number, number, number] = [cos, sin, -sin, cos, width / 2 - cos * outWidth / 2 + sin * outHeight / 2, height / 2 - sin * outWidth / 2 - cos * outHeight / 2]; sampleAffine(this.bitmap, width, height, out.bitmap, outWidth, outHeight, inverse, 0, 0, mode); + this.__moved__?.(1, 1, (width - outWidth) / 2, (height - outHeight) / 2); return this.__apply__(out); } diff --git a/tests/api-shapes.js b/tests/api-shapes.js index 2561b1d0..b4f01ca1 100644 --- a/tests/api-shapes.js +++ b/tests/api-shapes.js @@ -237,6 +237,62 @@ const throws = (call, type, message) => { assert(decoded.length === 2 && decoded.frames[0].duration === 40, 'encoder.add takes frames and images'); } + // every size-changing method of a frame moves its offsets, so the frame stays in place + { + const square = () => new Image(10, 10).fill((x, y) => x === 3 && y === 4 ? 0x00ff00ff : 0xff8040ff); + // the green pixel's position in the animation + const green = frame => { + for (const [x, y, c] of frame.image.pixels()) if (c === 0x00ff00ff) return `${frame.xOffset + x},${frame.yOffset + y}`; + return 'gone'; + }; + const shadowed = new Frame(square(), {xOffset: 2, yOffset: 5}).dropShadow({blur: 2}); + assert(shadowed.width > 10 && shadowed.xOffset < 2 && green(shadowed) === '5,9', `Frame.dropShadow: offsets ${shadowed.xOffset},${shadowed.yOffset}, green at ${green(shadowed)}`); + const outlined = new Frame(square(), {xOffset: 1, yOffset: 4}).outline({width: 3}); + assert(outlined.width === 16 && outlined.xOffset === -2 && outlined.yOffset === 1 && green(outlined) === '4,8', `Frame.outline: ${outlined.xOffset},${outlined.yOffset}`); + const sheared = new Frame(new Image(20, 20).fill(0xffffffff), {xOffset: 100, yOffset: 100}).transform([1, 0, 0.5, 1, 0, 0]); + assert(sheared.width === 30 && sheared.xOffset === 95 && sheared.yOffset === 100, `Frame.transform: ${sheared.width} at ${sheared.xOffset},${sheared.yOffset}`); + const turned = new Frame(new Image(20, 10), {xOffset: 10, yOffset: 10}).rotate(90); + assert(turned.width === 10 && turned.height === 20 && turned.xOffset === 15 && turned.yOffset === 5, `Frame.rotate(90) keeps its middle: ${turned.xOffset},${turned.yOffset}`); + const tilted = new Frame(new Image(20, 20), {xOffset: 50, yOffset: 50}).rotate(45); + assert(Math.abs(tilted.xOffset + tilted.width / 2 - 60) <= 0.5 && Math.abs(tilted.yOffset + tilted.height / 2 - 60) <= 0.5, 'Frame.rotate(45) keeps its middle'); + const contained = new Frame(new Image(20, 10), {xOffset: 10, yOffset: 6}).contain(40, 40); + assert(contained.width === 40 && contained.height === 40 && contained.xOffset === 20 && contained.yOffset === 2, `Frame.contain: ${contained.xOffset},${contained.yOffset}`); + const covered = new Frame(new Image(20, 10), {xOffset: 10, yOffset: 6}).cover(10, 10); + assert(covered.width === 10 && covered.xOffset === 15 && covered.yOffset === 6, `Frame.cover: ${covered.xOffset},${covered.yOffset}`); + const viaImage = new Frame(square(), {xOffset: 2, yOffset: 5}); + viaImage.image.dropShadow({blur: 2}); + assert(viaImage.xOffset === 2 && viaImage.yOffset === 5, 'frame.image.dropShadow leaves the offsets'); + assert(viaImage.image.__moved__ === undefined && !Object.keys(viaImage.image).includes('__moved__'), 'the offset tracking is removed afterwards'); + let error = null; + try { new Frame(square(), {xOffset: 2}).rotate(10, {mode: 'area'}); } catch (e) { error = e; } + assert(error instanceof TypeError, 'Frame.rotate checks its options'); + } + + // the same on animations: scale and trim keep the frames, the others work on the composited animation + { + const animation = () => new Animation([ + new Frame(new Image(30, 20).fill((x, y) => x === 0 && y === 0 ? 0x00ff00ff : 0xff0000ff), {duration: 40}), + new Frame(new Image(6, 4).fill(0x0000ffff), {xOffset: 10, yOffset: 8, duration: 60}) + ]); + const scaled = animation().scale(2); + assert(scaled.width === 60 && scaled.height === 40 && scaled.frames[1].xOffset === 20 && scaled.frames[1].width === 12, 'Animation.scale'); + const sparse = new Animation([new Frame(new Image(4, 3).fill(0xffffffff), {xOffset: 5, yOffset: 6}), new Frame(new Image(2, 2).fill(0xff0000ff), {xOffset: 12, yOffset: 7})], {width: 20, height: 20}); + sparse.trim(); + assert(sparse.width === 9 && sparse.height === 3 && sparse.frames[0].xOffset === 0 && sparse.frames[1].xOffset === 7 && sparse.frames[1].yOffset === 1, `Animation.trim: ${sparse.width}x${sparse.height}`); + const turned = animation().rotate(90); + assert(turned.width === 20 && turned.height === 30 && turned.length === 2 && turned.frames.every(f => f.width === 20 && f.height === 30 && f.xOffset === 0), 'Animation.rotate(90): full canvas frames'); + assert(turned.frames[0].image.getPixelAt(19, 0) === 0x00ff00ff && turned.frames.map(f => f.duration).join() === '40,60', 'Animation.rotate turns clockwise, keeps the durations'); + assert(turned.frames[1].image.getPixelAt(20 - 1 - 9, 11) === 0x0000ffff, 'Animation.rotate: the second frame composited over the first'); + const shadowed = animation().dropShadow({blur: 2}); + const outlined = animation().outline({width: 2}); + const skewed = animation().transform([1, 0, 0.5, 1, 0, 0]); + assert(shadowed.width > 30 && outlined.width === 34 && outlined.height === 24 && skewed.width === 40 && skewed.frames.every(f => f.width === skewed.width), 'Animation.dropShadow, outline and transform grow the canvas'); + const kept = animation(), first = kept.frames[0]; + let error = null; + try { kept.rotate(10, {mode: 'area'}); } catch (e) { error = e; } + assert(error instanceof TypeError && kept.frames[0] === first && kept.width === 30, 'Animation.rotate: nothing changes when it throws'); + } + // renamed parameters and the American spelling { const gray = new Image(1, 1).fill(0xff0000ff).grayscale(); From 48665d0fe51a50d98c28053c69a3db87cc97a774 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 22:29:38 +0000 Subject: [PATCH 067/188] document the frame and animation size changes and the dither defaults --- CHANGELOG.md | 8 ++++-- guides/Migrating to 4.0.md | 56 +++++++++++++++++++++++++++++++------- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b6514ac..6667abe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Breaking changes -Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation hints, then switch: the [migration guide](https://imagescript.dev/documents/Migrating_to_4.0.html) lists every change and the seven behavior changes that don't show up as deprecations or compile errors. +Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation hints, then switch: the [migration guide](https://imagescript.dev/documents/Migrating_to_4.0.html) lists every change and the nine behavior changes that don't show up as deprecations or compile errors. - **Methods that take options refuse anything else than an object**: a stray number, string, boolean or `null` as the options of `cropCircle()`, `Image.renderText()`, the encoders (`encodeWebP()`, `encodeAVIF()`, `encodeJXL()`, ...), `quantize()`, `transform()`, `dropShadow()`, `outline()`, the gradients, `Image.decode()`, the animation encoders, and `new GainMap()` throws a `TypeError` that names the working form (e.g. `cropCircle({ size: 'cover' })`), so the 3.x calls that are gone like `cropCircle(true)` or `Image.renderText(font, 32, 'Hi')` fail instead of being ignored. Only the type of the argument is checked: a key that isn't an option is still ignored, so a typo like `{ maxWith: 300 }` isn't caught. - **The mode constants are removed, modes are plain strings**: `Image.RESIZE_*`, `Image.SVG_MODE_*`, `Frame.DISPOSAL_*` and the `ResizeModes`, `SVGScaleModes` and `FrameDisposalModes` classes are gone. Resize modes are `'nearest-neighbor'`, `'bilinear'`, `'bicubic'` and `'area'` (the `ResizeMode` type), disposal modes `'any'`, `'keep'`, `'previous'` and `'background'` (`FrameDisposalModeName`), and the SVG modes became options of `renderSVG()` (below). `Image.RESIZE_AUTO`/`ResizeModes.AUTO` is `image.resize({ width: 256 })`. The old values are no longer accepted either: `'RESIZE_BILINEAR'`, the numbers 1-3 for SVG scale modes and the numeric disposal ids 0-3. @@ -67,10 +67,12 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **`encodeJPEG()` composites transparent pixels onto a background**, black by default (`background` sets the color), instead of dropping the alpha and showing the stored color. Opaque images encode as before. -- **`Frame` holds an image instead of extending `Image`**: `frame.image` is the image, `new Frame(image, options)` takes it (without a copy), and `Frame.from()` is removed. `resize()`, `scale()`, `crop()` and `trim()` stay on the frame and move its offsets; everything else is `frame.image.blur(2)` and the like, and leaves the offsets alone (`transform()`, `dropShadow()` and `outline()` no longer move them). +- **`Frame` holds an image instead of extending `Image`**: `frame.image` is the image, `new Frame(image, options)` takes it (without a copy), and `Frame.from()` is removed. The methods that change the size (`resize()`, `scale()`, `contain()`, `cover()`, `crop()`, `trim()`, `rotate()`, `transform()`, `dropShadow()` and `outline()`) stay on the frame and move its offsets so it stays in place; everything else is `frame.image.blur(2)` and the like. `frame.crop()` and `frame.rotate()` now move the offsets too (3.x kept them), `frame.cover()` moves them by the cropped amount, and the offsets can become negative where 3.x stopped the growth at 0. - **GIF palettes take a color count**: the `quality` of `Animation.encodeGIF()` and `Animation.gifEncoder()` is `colors` (2-256, default 256, which `quality` 90 and above gave), with the new `dither` and `ditherStrength`. Their options type is `GIFAnimationEncodeOptions`; `GIFEncodeOptions` is now the options of `Image.encodeGIF()`. +- **`quantize()` dithers by default**: Floyd-Steinberg at a strength of `0.85`, like the GIF encoders (3.x: no dithering, and a strength of `1` when dithering). `dither: 'none'` gives the old default. + - **Removed**: `getRGBAAt()` (use `Colors.toRGBA(image.getPixelAt(x, y))`). - **Renamed**: `greyscale()` is `grayscale()` (American spelling throughout), `region()` is `extract()`, the `resize` option of `rotate()` is `expand` (like `transform()`, `outline()` and `dropShadow()`), `GainMap`'s `gainMapMin` and `gainMapMax` (properties and options) are `min` and `max`, and the parameters of `setPixelAt()`, `lightness()` and `saturation()` are `color` and `factor`. @@ -89,6 +91,8 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - `resize({ width, height, fit: 'inside' })` on images, frames and animations: as large as fits inside the box, keeping the aspect ratio. - `encodeJPEG({ subsampling: '4:4:4' | '4:2:0' })`; the default stays 4:2:0 up to quality 90 and 4:4:4 above. - `Colors.gradient()` takes an array of evenly spaced colors, like the `stops` of the gradient functions. +- `Animation.scale()`, `Animation.trim()`, `Animation.rotate()`, `Animation.transform()`, `Animation.dropShadow()` and `Animation.outline()`. `scale()` and `trim()` keep the frames; the others work on the composited animation (full canvas frames) and resize the canvas. +- `Frame.contain()`, `Frame.cover()`, `Frame.rotate()`, `Frame.transform()`, `Frame.dropShadow()` and `Frame.outline()`, which move the offsets like `Frame.resize()` and `Frame.crop()`. - `Image.encodeGIF()` for single images, with `colors`, `dither` and `ditherStrength` (also on the animation encoders). - `renderSVG({ fonts })` takes a single font without an array. diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index 49175813..c15cf31e 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -1,7 +1,7 @@ # Migrating to 4.0 -ImageScript 4.0 removes everything 3.x deprecated, reshapes parts of the API, and changes seven behaviors. Most of the -removals are mechanical: the compiler points at them. The seven behavior changes are not deprecations, nothing warns +ImageScript 4.0 removes everything 3.x deprecated, reshapes parts of the API, and changes nine behaviors. Most of the +removals are mechanical: the compiler points at them. The nine behavior changes are not deprecations, nothing warns about them, and code keeps compiling while it does something else. Read the first section even if your build is clean. Node.js 22.13 or newer is required, as it was for 3.x (`engines` in `package.json`). @@ -13,7 +13,7 @@ The recommended path: problems list, or a lint rule for deprecated APIs (typescript-eslint has `no-deprecated`), to get the full list. 2. Fix every deprecation hint, using the tables below. 3. Switch to 4.0, fix what the compiler reports with the [renamed and reshaped APIs](#renamed-and-reshaped-apis), and go - through the seven behavior changes. The [checklist](#checklist) at the end has grep patterns + through the nine behavior changes. The [checklist](#checklist) at the end has grep patterns for finding the code they affect. ## Behavior changes @@ -153,6 +153,40 @@ for another color: `image.encodeJPEG({ background: 0xffffffff })`. Opaque images Search your code for: `encodeJPEG(` on images that can have transparency. +### `quantize()` dithers by default + +`image.quantize()` now dithers with Floyd-Steinberg at a strength of `0.85` by default, like the GIF encoders. In 3.x +it didn't dither unless you passed `dither`. For the old result, pass `dither: 'none'`: + +| 3.x | 4.0 | +| --- | --- | +| `image.quantize({ colors: 16 })` | `image.quantize({ colors: 16, dither: 'none' })` | +| `image.quantize({ colors: 16, dither: 'floyd-steinberg' })` (strength 1) | `image.quantize({ colors: 16, dither: 'floyd-steinberg', ditherStrength: 1 })` | + +The second row matters for every dither method: the default strength is `0.85` now, so pass `ditherStrength: 1` to +keep 3.x's result. + +Search your code for: `.quantize(`. + +### Frames move their offsets when their size changes + +A frame's `crop()`, `cover()` and `rotate()` now move its offsets, so the frame's pixels stay where they are in the +animation. In 3.x, `crop()` and `rotate()` kept the offsets (the top left corner stayed in place), and `cover()` only +scaled them. Now: + +- `frame.crop(x, y, w, h)` adds `x` and `y` to the offsets, like `trim()` already did. +- `frame.rotate(angle)` keeps the frame's middle in place. +- `frame.cover(w, h)` scales the offsets and then moves them by the cropped amount. + +`transform()`, `dropShadow()` and `outline()` moved the offsets in 3.x too, but only as far as they allowed (a frame at +`xOffset: 0` grew to the right instead). Now the offsets can become negative: the frame grows evenly, and the part +outside the animation's canvas isn't encoded. + +To keep a frame's offsets as they are, call the method on its image: `frame.image.crop(x, y, w, h)`. See +[frames hold an image](#frames-hold-an-image) for the other frame changes. + +Search your code for: `.crop(`, `.cover(` and `.rotate(` on frames. + ## Removed APIs Each table maps a 3.x API to its 4.0 replacement. The deprecation hint in 3.x names a replacement too, but some of those @@ -363,11 +397,13 @@ A `Frame` is no longer an `Image`: it holds one, `frame.image`, with the frame's | `image.composite(frame)`, `frame.encodePNG()` | `image.composite(frame.image)`, `frame.image.encodePNG()` | | `frame instanceof Image` | `false` now | -`resize()`, `scale()`, `crop()` and `trim()` stay on the frame and move its offsets so its pixels stay where they are in -the animation, and `width`, `height`, `clone()` and `toString()` work as before. Everything else goes through -`frame.image` and leaves the offsets alone. In 3.x, a frame that grew up or left from `transform()`, `dropShadow()` or -`outline()` moved its offsets with the growth (as far as they allowed); now the image grows and the offsets stay, so -move them yourself if the frame's pixels have to stay in place. +The methods that change the size stay on the frame: `resize()`, `scale()`, `contain()`, `cover()`, `crop()`, `trim()`, +`rotate()`, `transform()`, `dropShadow()` and `outline()`. They move the offsets so the frame stays in place in the +animation (resizing scales the offsets, as if the whole animation was resized); see +[frames move their offsets](#frames-move-their-offsets-when-their-size-changes) for how that differs from 3.x. +`width`, `height`, `clone()` and `toString()` work as before. Everything else goes through `frame.image`. The image's +own methods don't know about the frame and leave the offsets alone, so `frame.image.rotate(90)` turns the frame around +its top left corner. ### Checked values and the canvas @@ -448,6 +484,6 @@ grep -rnE 'gainMap\.encode\(|\.encode\(\s*[0-9]' src Also search for the variables that hold animations, and for `[0]`, `.map(`, `.filter(` and `.forEach(` on them: the search can't tell an animation from an array, so check the type of each receiver. -Then compile with `tsc`, and run the tests that cover your decode and encode paths. The seven behavior changes need +Then compile with `tsc`, and run the tests that cover your decode and encode paths. The nine behavior changes need runtime checks: a file with several frames, a one-frame GIF, any `loopCount: -1`, every `contain()`, every HSL hue, -every `rotate()` and every JPEG made from an image with transparency. +every `rotate()`, every JPEG made from an image with transparency, every `quantize()`, and the size changes of frames. From 0782797190ffa496ebc0975a01f471228bf4efc1 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Sun, 8 Nov 2026 22:34:16 +0000 Subject: [PATCH 068/188] tidy the changelog and migration guide --- CHANGELOG.md | 5 +++-- guides/Migrating to 4.0.md | 28 ++++++++++++++++------------ 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6667abe5..28dac142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation hints, then switch: the [migration guide](https://imagescript.dev/documents/Migrating_to_4.0.html) lists every change and the nine behavior changes that don't show up as deprecations or compile errors. - **Methods that take options refuse anything else than an object**: a stray number, string, boolean or `null` as the options of `cropCircle()`, `Image.renderText()`, the encoders (`encodeWebP()`, `encodeAVIF()`, `encodeJXL()`, ...), `quantize()`, `transform()`, `dropShadow()`, `outline()`, the gradients, `Image.decode()`, the animation encoders, and `new GainMap()` throws a `TypeError` that names the working form (e.g. `cropCircle({ size: 'cover' })`), so the 3.x calls that are gone like `cropCircle(true)` or `Image.renderText(font, 32, 'Hi')` fail instead of being ignored. Only the type of the argument is checked: a key that isn't an option is still ignored, so a typo like `{ maxWith: 300 }` isn't caught. + - **The mode constants are removed, modes are plain strings**: `Image.RESIZE_*`, `Image.SVG_MODE_*`, `Frame.DISPOSAL_*` and the `ResizeModes`, `SVGScaleModes` and `FrameDisposalModes` classes are gone. Resize modes are `'nearest-neighbor'`, `'bilinear'`, `'bicubic'` and `'area'` (the `ResizeMode` type), disposal modes `'any'`, `'keep'`, `'previous'` and `'background'` (`FrameDisposalModeName`), and the SVG modes became options of `renderSVG()` (below). `Image.RESIZE_AUTO`/`ResizeModes.AUTO` is `image.resize({ width: 256 })`. The old values are no longer accepted either: `'RESIZE_BILINEAR'`, the numbers 1-3 for SVG scale modes and the numeric disposal ids 0-3. - **`absolute` is removed from `opacity()`, `red()`, `green()`, `blue()`, `lightness()` and `saturation()`**: they only scale now, `image.red(0.5)` (and `AdjustOptions`, the type of their options, is removed). For a fixed color, use `image.recolor()`. In JavaScript a leftover `image.red(0.5, true)` scales the channel instead of setting it. @@ -83,7 +84,7 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **An animation has its own canvas size**: decoded animations keep the file's, `new Animation(frames, { width, height })` sets one, and `resize()` sets the new one. Without one it is the frames' extent, as before. Frames (or their parts) outside the canvas aren't encoded. -- **The encoders return `Promise>`**, which `new Blob()`, `new Response()` and `crypto.subtle` take without a cast in TypeScript 5.9 and newer (needs TypeScript 5.7). +- **The encoders return `Promise>`** instead of `Promise`, so `new Blob()`, `new Response()` and `crypto.subtle` take the output without a cast, also with the stricter DOM types of TypeScript 5.9 and newer. The type needs TypeScript 5.7 or newer. ### New @@ -93,7 +94,7 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - `Colors.gradient()` takes an array of evenly spaced colors, like the `stops` of the gradient functions. - `Animation.scale()`, `Animation.trim()`, `Animation.rotate()`, `Animation.transform()`, `Animation.dropShadow()` and `Animation.outline()`. `scale()` and `trim()` keep the frames; the others work on the composited animation (full canvas frames) and resize the canvas. - `Frame.contain()`, `Frame.cover()`, `Frame.rotate()`, `Frame.transform()`, `Frame.dropShadow()` and `Frame.outline()`, which move the offsets like `Frame.resize()` and `Frame.crop()`. -- `Image.encodeGIF()` for single images, with `colors`, `dither` and `ditherStrength` (also on the animation encoders). +- `Image.encodeGIF()` for single images, with `colors`, `dither` and `ditherStrength` (also on the animation encoders), and the `DitherMethod` type that `quantize()` and the GIF encoders share. - `renderSVG({ fonts })` takes a single font without an array. ## 3.20.1 diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index c15cf31e..f8bcab2a 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -97,9 +97,9 @@ new Animation(frames, { loopCount: Infinity }); animation.loopCount === Infinity; ``` -The finite counts are the same: `0` plays once, `2` plays three times. The valid values are `Infinity` and integers from -0 ({@link AnimationOptions.loopCount}). `-1` throws a `RangeError`, like other negative numbers, `NaN` and fractions. Numeric strings, `null` and booleans -throw a `TypeError`. The check applies to the constructor, to the setter `animation.loopCount = ...` (which keeps the +The finite counts are the same: `0` plays once, `2` plays three times. The valid values are `Infinity` and integers +from 0 ({@link AnimationOptions.loopCount}). `-1` throws a `RangeError`, like other negative numbers, `NaN` and +fractions. Numeric strings, `null` and booleans throw a `TypeError`. The check applies to the constructor, to the setter `animation.loopCount = ...` (which keeps the old count when it throws), and to the encoders `Animation.gifEncoder()`, `webpEncoder()` and `apngEncoder()`. The formats store a finite count in fixed-size fields: a GIF up to `65535`, a WebP up to `65534` and an APNG up to @@ -178,9 +178,10 @@ scaled them. Now: - `frame.rotate(angle)` keeps the frame's middle in place. - `frame.cover(w, h)` scales the offsets and then moves them by the cropped amount. -`transform()`, `dropShadow()` and `outline()` moved the offsets in 3.x too, but only as far as they allowed (a frame at -`xOffset: 0` grew to the right instead). Now the offsets can become negative: the frame grows evenly, and the part -outside the animation's canvas isn't encoded. +`transform()`, `dropShadow()` and `outline()` moved the offsets in 3.x too, but only as far as they allowed: a frame at +`xOffset: 0` couldn't grow to the left, so a shadow or outline was cut off on that side and a transform grew to the +right instead. Now the frame grows the same way an image does and the offsets can become negative; the part outside the +animation's canvas isn't encoded. To keep a frame's offsets as they are, call the method on its image: `frame.image.crop(x, y, w, h)`. See [frames hold an image](#frames-hold-an-image) for the other frame changes. @@ -264,8 +265,8 @@ for (let i = 0; i < image.bitmap.length; i += 4) image.bitmap[i] = 0.5 * 255; ``` `bitmap` is RGBA, four bytes per pixel. Writing it directly skips the gain map of an HDR image, so do not use it on -images that have one. `absolute` on `lightness()` and `saturation()` sets the value of every pixel and keeps its hue. Do it per pixel: -read the color with `Colors.toHSLA()`, set the lightness or saturation, and write the pixel back with +images that have one. `absolute` on `lightness()` and `saturation()` sets the value of every pixel and keeps its hue. +Do it per pixel: read the color with `Colors.toHSLA()`, set the lightness or saturation, and write the pixel back with `Colors.fromHSLA()`. ### Animations and GIF @@ -333,7 +334,7 @@ object under the names of their options. The options of each method are on its o | `image.dominantColor(true, true, 15)` | `image.dominantColor({ ignoreBlack: true, ignoreWhite: true, threshold: 15 })` | | `image.roundCorners(8)` | `image.roundCorners({ radius: 8 })` | | `image.fisheye(3)` | `image.fisheye({ strength: 3 })` | -| `image.trim(color, options)`, `frame.trim(color, options)` | `image.trim({ color, ...options })` | +| `image.trim(color, options)`, `frame.trim(color, options)` | `image.trim({ color, ...options })`, `frame.trim({ color, ...options })` | | `image.colorToAlpha(color, options)` | `image.colorToAlpha({ color, ...options })` | | `image.encodePNG(1, { author })` | `image.encodePNG({ compression: 1, metadata: { author } })` | | `image.encodePNG({ author })` (metadata alone) | `image.encodePNG({ metadata: { author } })`. The old form is ignored, see below | @@ -402,8 +403,8 @@ The methods that change the size stay on the frame: `resize()`, `scale()`, `cont animation (resizing scales the offsets, as if the whole animation was resized); see [frames move their offsets](#frames-move-their-offsets-when-their-size-changes) for how that differs from 3.x. `width`, `height`, `clone()` and `toString()` work as before. Everything else goes through `frame.image`. The image's -own methods don't know about the frame and leave the offsets alone, so `frame.image.rotate(90)` turns the frame around -its top left corner. +own methods don't know about the frame and leave the offsets alone: after `frame.image.rotate(90)`, the frame's top +left corner stays where it was, and the turned image hangs from it. ### Checked values and the canvas @@ -474,9 +475,12 @@ grep -rnE '\.(contain|fit)\(|greyscale|drawBox|drawCircle|SVGScaleMode|TextLayou grep -rnE 'fromHSLA?\(|toHSLA\(|\.rotate\(|renderSVG\([^)]*(size|mode)|layout:|getRGBAAt|\.region\(' src grep -rnE 'encodeGIF\(|gifEncoder\(|GIFEncodeOptions|encodeJPEG\(' src -# frames: anything an Image does, on a frame +# frames: anything an Image does, on a frame, and the size changes that move the offsets now grep -rnE 'new Frame\(|Frame\.from\(|frame\w*\.[a-zA-Z]+\(' src +# behavior changes without a compile error +grep -rnE '\.(rotate|quantize|contain|encodeJPEG)\(|from(HSLA?)\(|toHSLA\(' src + # GainMap grep -rnE 'gainMap\.encode\(|\.encode\(\s*[0-9]' src ``` From aa385b47f5dd8ed9afaa3ee8310f026cf7b8ef92 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Mon, 9 Nov 2026 05:46:25 +0000 Subject: [PATCH 069/188] define DitherMethod once --- src/codecs/gif.ts | 3 ++- src/codecs/quantize.ts | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/codecs/gif.ts b/src/codecs/gif.ts index 9c54bc77..22bb40a3 100644 --- a/src/codecs/gif.ts +++ b/src/codecs/gif.ts @@ -1,5 +1,6 @@ import { ByteWriter } from '../util/bytes.ts'; -import { quantize, type DitherMethod } from './quantize.ts'; +import type { DitherMethod } from '../image.ts'; +import { quantize } from './quantize.ts'; import { MAX_PIXELS, PixelBudget, checkPixels } from '../util/limits.ts'; // ImageScript disposal modes (any, keep, previous, background) <-> GIF disposal methods (unspecified, none, background, previous) diff --git a/src/codecs/quantize.ts b/src/codecs/quantize.ts index da7c980e..efb25a13 100644 --- a/src/codecs/quantize.ts +++ b/src/codecs/quantize.ts @@ -1,3 +1,4 @@ +import type { DitherMethod } from '../image.ts'; // perceptual channel weights for squared color distances const WR = 3, WG = 4, WB = 2; @@ -14,9 +15,9 @@ export interface QuantizeOptions { transparent?: boolean; } -/** The dithering methods of Image.quantize() and the GIF encoders */ -export const DITHER_METHODS = ['none', 'floyd-steinberg', 'atkinson', 'ordered'] as const; -export type DitherMethod = typeof DITHER_METHODS[number]; +// every DitherMethod (a missing key is a type error) +const DITHER: Record = { 'none': true, 'floyd-steinberg': true, 'atkinson': true, 'ordered': true }; +export const DITHER_METHODS = Object.keys(DITHER) as DitherMethod[]; /** * Checks palette options (`colors` from 2 to 256, a dither method, a strength 0-1) before anything changes, with the @@ -185,7 +186,7 @@ const ATKINSON = [[1, 0, 1 / 8], [2, 0, 1 / 8], [-1, 1, 1 / 8], [0, 1, 1 / 8], [ * the neighbors that come after it (serpentine: rows alternate direction), `'ordered'` offsets each pixel by a 4x4 * Bayer threshold. `strength` (0-1) scales the spread error and the threshold offset; 0 changes nothing but rounding */ -export function applyPalette(pixels: Uint8Array, width: number, height: number, palette: Uint8Array, dither: 'none' | 'floyd-steinberg' | 'atkinson' | 'ordered', strength: number): void { +export function applyPalette(pixels: Uint8Array, width: number, height: number, palette: Uint8Array, dither: DitherMethod, strength: number): void { const entries = palette.length / 3; if (!entries) return; const nearest = nearestSearch(palette, entries); From d208b6f80c151e670e20efd8d2e14371ceb5907f Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Mon, 9 Nov 2026 05:46:25 +0000 Subject: [PATCH 070/188] rename FrameDisposalModeName, SVGOptions and TextStyle to FrameDisposalMode, RenderSVGOptions and TextStyleOptions --- CHANGELOG.md | 6 ++++-- guides/Migrating to 4.0.md | 11 +++++++---- src/frame.ts | 14 +++++++------- src/image.ts | 18 +++++++++--------- src/index.ts | 4 ++-- 5 files changed, 29 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28dac142..1b5d8ca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **Methods that take options refuse anything else than an object**: a stray number, string, boolean or `null` as the options of `cropCircle()`, `Image.renderText()`, the encoders (`encodeWebP()`, `encodeAVIF()`, `encodeJXL()`, ...), `quantize()`, `transform()`, `dropShadow()`, `outline()`, the gradients, `Image.decode()`, the animation encoders, and `new GainMap()` throws a `TypeError` that names the working form (e.g. `cropCircle({ size: 'cover' })`), so the 3.x calls that are gone like `cropCircle(true)` or `Image.renderText(font, 32, 'Hi')` fail instead of being ignored. Only the type of the argument is checked: a key that isn't an option is still ignored, so a typo like `{ maxWith: 300 }` isn't caught. -- **The mode constants are removed, modes are plain strings**: `Image.RESIZE_*`, `Image.SVG_MODE_*`, `Frame.DISPOSAL_*` and the `ResizeModes`, `SVGScaleModes` and `FrameDisposalModes` classes are gone. Resize modes are `'nearest-neighbor'`, `'bilinear'`, `'bicubic'` and `'area'` (the `ResizeMode` type), disposal modes `'any'`, `'keep'`, `'previous'` and `'background'` (`FrameDisposalModeName`), and the SVG modes became options of `renderSVG()` (below). `Image.RESIZE_AUTO`/`ResizeModes.AUTO` is `image.resize({ width: 256 })`. The old values are no longer accepted either: `'RESIZE_BILINEAR'`, the numbers 1-3 for SVG scale modes and the numeric disposal ids 0-3. +- **The mode constants are removed, modes are plain strings**: `Image.RESIZE_*`, `Image.SVG_MODE_*`, `Frame.DISPOSAL_*` and the `ResizeModes`, `SVGScaleModes` and `FrameDisposalModes` classes are gone. Resize modes are `'nearest-neighbor'`, `'bilinear'`, `'bicubic'` and `'area'` (the `ResizeMode` type), disposal modes `'any'`, `'keep'`, `'previous'` and `'background'` (`FrameDisposalMode`), and the SVG modes became options of `renderSVG()` (below). `Image.RESIZE_AUTO`/`ResizeModes.AUTO` is `image.resize({ width: 256 })`. The old values are no longer accepted either: `'RESIZE_BILINEAR'`, the numbers 1-3 for SVG scale modes and the numeric disposal ids 0-3. - **`absolute` is removed from `opacity()`, `red()`, `green()`, `blue()`, `lightness()` and `saturation()`**: they only scale now, `image.red(0.5)` (and `AdjustOptions`, the type of their options, is removed). For a fixed color, use `image.recolor()`. In JavaScript a leftover `image.red(0.5, true)` scales the channel instead of setting it. @@ -54,7 +54,7 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **Loop counts a format can't store throw a `RangeError`**: a GIF stores up to 65535 loops, a WebP up to 65534 and an APNG up to 2147483646 (2^31 - 2). A larger count used to wrap around silently (65536 as a GIF was forever). The encoders and the streaming encoders refuse it before encoding; `Infinity` is always fine. -- **Removed and narrowed exported types**: `TextOptions` is `RenderTextOptions`, `FrameDisposalModeId` is removed (`FrameDisposalModeName` has the names), `AdjustOptions` is removed with `absolute`, `PNGMetadata` is `Metadata`, and `AnimationEncoderOptions`, `WebPAnimationEncoderOptions`, `GIFAnimationEncoderOptions` and `APNGAnimationEncoderOptions` are `AnimationEncodeOptions`, `WebPAnimationEncodeOptions`, `GIFAnimationEncodeOptions` and `APNGEncodeOptions`. `ResizeMode` is narrowed to the lower-case names (`'area'`, ...), and `SVGScaleMode` is removed. +- **Removed and narrowed exported types**: `TextOptions` is `RenderTextOptions`, `FrameDisposalModeId` is removed (`FrameDisposalMode` has the names), `AdjustOptions` is removed with `absolute`, `PNGMetadata` is `Metadata`, and `AnimationEncoderOptions`, `WebPAnimationEncoderOptions`, `GIFAnimationEncoderOptions` and `APNGAnimationEncoderOptions` are `AnimationEncodeOptions`, `WebPAnimationEncodeOptions`, `GIFAnimationEncodeOptions` and `APNGEncodeOptions`. `ResizeMode` is narrowed to the lower-case names (`'area'`, ...), and `SVGScaleMode` is removed. - **`contain()` pads to the box, `fit()` is removed**: `image.contain(w, h)` resizes the image to fit inside the box and centers it on a canvas of exactly that size, like CSS' `object-fit: contain` (what `fit()` did), with transparent bars or a `background` color. The resize without bars, which `contain()` did, is `resize({ width, height, fit: 'inside' })`. @@ -76,6 +76,8 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **Removed**: `getRGBAAt()` (use `Colors.toRGBA(image.getPixelAt(x, y))`). +- **Renamed types**: `FrameDisposalModeName` is `FrameDisposalMode`, `SVGOptions` is `RenderSVGOptions` (like `RenderTextOptions`) and `TextStyle` is `TextStyleOptions` (like `TextLayoutOptions`). They are types only, nothing changes at runtime. + - **Renamed**: `greyscale()` is `grayscale()` (American spelling throughout), `region()` is `extract()`, the `resize` option of `rotate()` is `expand` (like `transform()`, `outline()` and `dropShadow()`), `GainMap`'s `gainMapMin` and `gainMapMax` (properties and options) are `min` and `max`, and the parameters of `setPixelAt()`, `lightness()` and `saturation()` are `color` and `factor`. - **Fewer types and classes**: `TextLayout` isn't exported, `renderText()` takes the layout options with its other options (`{ font, size, maxWidth: 300 }`). `AnimationDecodeOptions` is `DecodeOptions`, which all decode functions take. `encodeTGA()` takes `TGAEncodeOptions`; `EncodeOptions` is the `metadata` that every encoder's options extend, and the `gainMap` option of the JPEG, AVIF and JPEG XL encoders is `boolean | GainMapEmbedOptions`. diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index f8bcab2a..eb74dff6 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -192,7 +192,7 @@ Search your code for: `.crop(`, `.cover(` and `.rotate(` on frames. Each table maps a 3.x API to its 4.0 replacement. The deprecation hint in 3.x names a replacement too, but some of those replacements were reshaped in 4.0 as well (for example `ResizeModes`, which became plain strings); the tables give the -4.0 form. The replacements are documented on their own pages: {@link ResizeMode}, {@link FrameDisposalModeName}, +4.0 form. The replacements are documented on their own pages: {@link ResizeMode}, {@link FrameDisposalMode}, {@link Colors}, {@link Frame}, {@link Animation}, {@link AnimationEncoder} and {@link Metadata}. ### Constants and modes @@ -214,7 +214,7 @@ replacements were reshaped in 4.0 as well (for example `ResizeModes`, which beca | Numeric disposal ids: `frame.disposalMode = 3`, `disposalMode: 3` in the frame options | The names: `frame.disposalMode = 'background'`, `disposalMode: 'background'`. `FrameDisposalModeId` is removed | The modes are plain strings in 4.0: the `ResizeModes` and `FrameDisposalModes` classes are removed, and the types -{@link ResizeMode} and {@link FrameDisposalModeName} list the names. The old string and number values are not accepted +{@link ResizeMode} and {@link FrameDisposalMode} list the names. The old string and number values are not accepted any more, so `image.resize(256, 256, { mode: 'RESIZE_BICUBIC' })` throws. ### Colors, pixels and encoding on `Image` @@ -317,7 +317,7 @@ Methods that took options as their last positional arguments take the options ob object under the names of their options. The options of each method are on its own page, for example {@link ResizeOptions}, {@link CompositeOptions}, {@link RotateOptions}, {@link SharpenOptions}, {@link DominantColorOptions}, {@link RoundCornersOptions}, {@link FisheyeOptions}, {@link TrimOptions}, -{@link ColorToAlphaOptions}, {@link PNGEncodeOptions}, {@link JPEGEncodeOptions}, {@link SVGOptions} and +{@link ColorToAlphaOptions}, {@link PNGEncodeOptions}, {@link JPEGEncodeOptions}, {@link RenderSVGOptions} and {@link FrameOptions}. The animation options are {@link GIFAnimationEncodeOptions}, {@link APNGEncodeOptions}, {@link DecodeOptions} and {@link GainMapEncodeOptions} for `GainMap.encode()`. @@ -379,6 +379,9 @@ false })` grows the image again), so search for them too. | `image.getRGBAAt(x, y)` | `Colors.toRGBA(image.getPixelAt(x, y))`, an array (`getRGBAAt()` returned a view into the bitmap) | | `image.region(x, y, w, h)` | `image.extract(x, y, w, h)` | | `animation.encodeGIF({ quality })`, `Animation.gifEncoder(w, h, { quality })` | `{ colors }`: the palette size, 2-256. A quality of 90 and above is the default `256`; below, `colors: Math.round(8 * 32 ** (quality / 90))` gives the old palette size. `dither` (`'floyd-steinberg'` by default, `'atkinson'`, `'ordered'` or `'none'`) and `ditherStrength` are new | +| `FrameDisposalModeName` (type) | `FrameDisposalMode`: the `Name` suffix is gone, as `FrameDisposalModeId` is | +| `SVGOptions` (type) | `RenderSVGOptions`, like `RenderTextOptions` | +| `TextStyle` (type) | `TextStyleOptions`, like `TextLayoutOptions` | | `GIFEncodeOptions` (the options of `Animation.encodeGIF()`) | `GIFAnimationEncodeOptions`. `GIFEncodeOptions` is now the options of the new `Image.encodeGIF()` | | `AnimationDecodeOptions` (type) | `DecodeOptions`, which `Image.decode()`, `Animation.decode()` and `decode()` all take | | `EncodeOptions` (type) as the options of `encodeTGA()` | `TGAEncodeOptions`. `EncodeOptions` is now the `metadata` that all encoder options extend | @@ -453,7 +456,7 @@ grep -rnE 'rgbaToColor|rgbToColor|hslaToColor|hslToColor|rgbaToHSLA|colorToRGBA? grep -rnE 'iterateWithColors|for \(const \[[a-z]+, ?[a-z]+\] of' src # types and classes -grep -rnE 'PNGMetadata|AnimationEncoderOptions|AdjustOptions|TextOptions|FrameDisposalModeId|\bGIF\b' src +grep -rnE 'PNGMetadata|AnimationEncoderOptions|AdjustOptions|TextOptions|FrameDisposalModeId|FrameDisposalModeName|SVGOptions|\bTextStyle\b|\bGIF\b' src # decode and animations grep -rnE 'decode\(.*, *(true|false)\)' src diff --git a/src/frame.ts b/src/frame.ts index d9708d50..eb4ee784 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -8,12 +8,12 @@ import { checkOptions, finite } from './util/options.ts'; * - `'previous'`: the frame's area goes back to what it was before the frame * - `'any'`: the decoder decides (in practice like `'keep'`) */ -export type FrameDisposalModeName = 'any' | 'keep' | 'previous' | 'background'; +export type FrameDisposalMode = 'any' | 'keep' | 'previous' | 'background'; // the ids the library stores the modes as (0 any, 1 keep, 2 previous, 3 background, like GIFs) type DisposalId = 0 | 1 | 2 | 3; -const DISPOSAL_MODES: readonly FrameDisposalModeName[] = ['any', 'keep', 'previous', 'background']; +const DISPOSAL_MODES: readonly FrameDisposalMode[] = ['any', 'keep', 'previous', 'background']; /** Options of {@link Frame} */ export interface FrameOptions { @@ -24,7 +24,7 @@ export interface FrameOptions { /** The frame's offset on the y-axis. Default: `0` */ yOffset?: number; /** The frame's disposal mode, see {@link Frame.disposalMode}. Default: `'keep'` */ - disposalMode?: FrameDisposalModeName; + disposalMode?: FrameDisposalMode; } /** @@ -53,7 +53,7 @@ export class Frame { * @internal * @ignore */ - static __convert_disposal_mode__(mode: FrameDisposalModeName): DisposalId { + static __convert_disposal_mode__(mode: FrameDisposalMode): DisposalId { const id = DISPOSAL_MODES.indexOf(mode); if (id < 0) throw new RangeError('Invalid disposal mode'); @@ -147,8 +147,8 @@ export class Frame { this.__y__ = finite(y, 'y offset'); } - /** The frame's disposal mode, see {@link FrameDisposalModeName} */ - get disposalMode(): FrameDisposalModeName { + /** The frame's disposal mode, see {@link FrameDisposalMode} */ + get disposalMode(): FrameDisposalMode { return DISPOSAL_MODES[this.__disposalMode__]; } @@ -165,7 +165,7 @@ export class Frame { * Sets the disposal mode * @param disposalMode The mode's name (`'any'`, `'keep'`, `'previous'`, `'background'`) */ - set disposalMode(disposalMode: FrameDisposalModeName) { + set disposalMode(disposalMode: FrameDisposalMode) { this.__disposalMode__ = Frame.__convert_disposal_mode__(disposalMode); } diff --git a/src/image.ts b/src/image.ts index 99615058..883fc08a 100644 --- a/src/image.ts +++ b/src/image.ts @@ -362,7 +362,7 @@ export interface FisheyeOptions { } /** Options of {@link Image.renderSVG} */ -export interface SVGOptions { +export interface RenderSVGOptions { /** * The width in pixels, the height follows the SVG's aspect ratio. Give one of `width`, `height` and `scale`. * Default: the SVG's own width @@ -397,9 +397,9 @@ export interface SVGOptions { /** * Options of {@link Image.renderText}: the font and size, the text color, the layout (wrapping, cutting off and alignment, - * see {@link TextLayoutOptions}) and the style (see {@link TextStyle}) + * see {@link TextLayoutOptions}) and the style (see {@link TextStyleOptions}) */ -export interface RenderTextOptions extends TextStyle, TextLayoutOptions { +export interface RenderTextOptions extends TextStyleOptions, TextLayoutOptions { /** * TrueType (ttf/ttc), OpenType (otf) or WOFF font buffer to use, or several by priority: every character comes * from the first font that has it (e.g. `[text, emoji]`), the first one sets the line height. Color glyphs (COLR @@ -512,7 +512,7 @@ export interface AVIFEncodeOptions extends EncodeOptions { } /** Text style options of {@link Image.renderText} */ -export interface TextStyle { +export interface TextStyleOptions { /** Width of an outline around the glyphs in pixels. Default: `0` */ strokeWidth?: number; /** Color of the outline. Default: `0x000000ff` */ @@ -2680,8 +2680,8 @@ export class Image { * Creates a new image from the given SVG. * Supports shapes, paths, transforms, use/symbol, CSS styles, gradients, patterns, strokes, markers, clip paths, * masks, opacity, blend modes, filters (all primitives and the filter functions) and images embedded as data URIs - * (PNG, JPEG, GIF, WebP, SVG) or given with {@link SVGOptions.images}, and text with the given or embedded fonts - * (see {@link SVGOptions.fonts}). Hrefs are resolved from the options and the data URIs only, never from files or URLs. + * (PNG, JPEG, GIF, WebP, SVG) or given with {@link RenderSVGOptions.images}, and text with the given or embedded fonts + * (see {@link RenderSVGOptions.fonts}). Hrefs are resolved from the options and the data URIs only, never from files or URLs. * @example * ```ts * const logo = await Image.renderSVG( @@ -2690,11 +2690,11 @@ export class Image { * ); * ``` * @param svg The SVG content - * @param options See {@link SVGOptions} + * @param options See {@link RenderSVGOptions} * @return The rendered SVG graphic */ - static renderSVG(svg: string | BinaryData, options?: SVGOptions): Promise; - static async renderSVG(svg: string | BinaryData, options: SVGOptions = {}): Promise { + static renderSVG(svg: string | BinaryData, options?: RenderSVGOptions): Promise; + static async renderSVG(svg: string | BinaryData, options: RenderSVGOptions = {}): Promise { checkOptions(options, 'renderSVG(svg, { width: 512 })'); const { width: givenWidth, height: givenHeight, scale, fonts = [], images: givenImages = {} } = options; const sizes = [givenWidth, givenHeight, scale].filter(value => value !== undefined).length; diff --git a/src/index.ts b/src/index.ts index e91742fe..2121afa8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,9 +9,9 @@ import { ImageType } from './image-type.ts'; export { Image } from './image.ts'; export { Colors } from './colors.ts'; -export type { Color, ColorFunction, ResizeMode, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, ContainOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, TGAEncodeOptions, GIFEncodeOptions, DitherMethod, GainMapEmbedOptions, SVGOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts'; +export type { Color, ColorFunction, ResizeMode, TextStyleOptions, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, ContainOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, TGAEncodeOptions, GIFEncodeOptions, DitherMethod, GainMapEmbedOptions, RenderSVGOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts'; export { Frame } from './frame.ts'; -export type { FrameDisposalModeName, FrameOptions } from './frame.ts'; +export type { FrameDisposalMode, FrameOptions } from './frame.ts'; export { GainMap } from './gainmap.ts'; export type { GainMapOptions, GainMapEncodeOptions } from './gainmap.ts'; export { Animation } from './animation.ts'; From abd680614e7db23779bb669782faef4b8832bf22 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Mon, 9 Nov 2026 19:42:04 +0100 Subject: [PATCH 071/188] fix: always return Uint8Array --- src/animation-encoder.ts | 4 ++-- src/codecs/avif-enc.ts | 2 +- src/codecs/bmp.ts | 2 +- src/codecs/gainmap.ts | 8 ++++---- src/codecs/gif.ts | 2 +- src/codecs/jpeg.ts | 2 +- src/codecs/jxl.ts | 4 ++-- src/codecs/jxl/fast-lossless.ts | 4 ++-- src/codecs/jxl/modular-lossless.ts | 2 +- src/codecs/jxl/vardct-enc.ts | 4 ++-- src/codecs/netpbm.ts | 2 +- src/codecs/png.ts | 8 ++++---- src/codecs/qoi.ts | 2 +- src/codecs/tga.ts | 2 +- src/codecs/vp8l.ts | 2 +- src/codecs/webp.ts | 10 +++++----- src/util/bytes.ts | 2 +- src/util/mem.ts | 2 +- 18 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/animation-encoder.ts b/src/animation-encoder.ts index 7816a0c2..8da3da3c 100644 --- a/src/animation-encoder.ts +++ b/src/animation-encoder.ts @@ -14,7 +14,7 @@ import type { AnimationOptions, WebPAnimationEncodeOptions, GIFAnimationEncodeOp interface Format { readonly optimize: OptimizeOptions; frame(f: OptimizedFrame | InputFrame, optimized: boolean): Promise; - file(optimizer: Optimizer | null): Promise> | Uint8Array; + file(optimizer: Optimizer | null): Promise> | Uint8Array; } /** @@ -218,7 +218,7 @@ function webpFrame(f: InputFrame): webp.WebPEncodeFrame { return { x, y, width: w, height: h, duration: f.duration, dispose: f.dispose === 1, pixels }; } -function concat(parts: Uint8Array[]): Uint8Array { +function concat(parts: Uint8Array[]): Uint8Array { const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let offset = 0; for (const p of parts) { diff --git a/src/codecs/avif-enc.ts b/src/codecs/avif-enc.ts index 8e351cd2..723265bc 100644 --- a/src/codecs/avif-enc.ts +++ b/src/codecs/avif-enc.ts @@ -182,7 +182,7 @@ function seqMonochrome(d: Uint8Array, o: number): boolean { } /** Encodes RGBA pixels as an AVIF (with an alpha image if the image has transparency) */ -export async function encode(rgba: Uint8Array, width: number, height: number, { quality, alphaQuality, subsampling, effort, exif, threads: maxThreads, gainMap }: AVIFOptions): Promise { +export async function encode(rgba: Uint8Array, width: number, height: number, { quality, alphaQuality, subsampling, effort, exif, threads: maxThreads, gainMap }: AVIFOptions): Promise> { if (width < 1 || height < 1) throw new RangeError('AVIF: dimensions must be at least 1'); if (width > 65536 || height > 65536) throw new RangeError('AVIF: dimensions must be at most 65536'); let opaque = true; diff --git a/src/codecs/bmp.ts b/src/codecs/bmp.ts index f618c149..2c558848 100644 --- a/src/codecs/bmp.ts +++ b/src/codecs/bmp.ts @@ -264,7 +264,7 @@ export function decode(d: Uint8Array, maxPixels = MAX_PIXELS): Decoded { * Encodes RGBA pixels into a BMP: a palette of 2, 16 or 256 colors if an opaque image has at most that many, else * 24 bit pixels, or 32 bit ones with an alpha mask (a version 4 header) if the image has transparency */ -export function encode(rgba: Uint8Array, width: number, height: number): Uint8Array { +export function encode(rgba: Uint8Array, width: number, height: number): Uint8Array { if (width < 1 || height < 1) throw new RangeError('BMP: dimensions must be at least 1'); const count = width * height; if (rgba.byteOffset % 4) rgba = rgba.slice(0, count * 4); diff --git a/src/codecs/gainmap.ts b/src/codecs/gainmap.ts index 645a0d6b..e9940591 100644 --- a/src/codecs/gainmap.ts +++ b/src/codecs/gainmap.ts @@ -398,7 +398,7 @@ const segmentBytes = (marker: number, body: Uint8Array): Uint8Array => { out.set(body, 4); return out; }; -const join = (parts: Uint8Array[]): Uint8Array => { +const join = (parts: Uint8Array[]): Uint8Array => { const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let o = 0; for (const p of parts) { @@ -424,14 +424,14 @@ export function stripMetadata(jpeg: Uint8Array): Uint8Array { } // inserts segments after a JPEG's APP0 (JFIF) segment if it starts with one, else after SOI -function insert(jpeg: Uint8Array, added: Uint8Array[]): Uint8Array { +function insert(jpeg: Uint8Array, added: Uint8Array[]): Uint8Array { let at = 2; if (jpeg[2] === 0xff && jpeg[3] === 0xe0) at = 4 + (jpeg[4] << 8 | jpeg[5]); return join([jpeg.subarray(0, at), ...added, jpeg.subarray(at)]); } /** A gain map JPEG with its metadata (hdrgm XMP and ISO 21496-1), from its compressed pixels */ -export function mapWithMetadata(body: Uint8Array, p: Params): Uint8Array { +export function mapWithMetadata(body: Uint8Array, p: Params): Uint8Array { const added = [segmentBytes(0xe1, join([ascii(XMP_NAMESPACE), ascii(writeMapXMP(p))])), segmentBytes(0xe2, join([ascii(ISO_NAMESPACE), writeISO(p)]))]; return insert(stripMetadata(body), added); } @@ -448,7 +448,7 @@ export function primarySegments(mapLength: number): Uint8Array[] { } /** Fills in the primary's MPF index (big endian like libultrahdr) and appends the gain map */ -export function finish(primary: Uint8Array, map: Uint8Array): Uint8Array { +export function finish(primary: Uint8Array, map: Uint8Array): Uint8Array { const mpf = segments(primary).find(({ marker, body }) => marker === 0xe2 && startsWith(body, 0, MPF_SIGNATURE)); if (!mpf) throw new Error('JPEG: missing MPF segment'); const tiff = mpf.start + 4 + 4; diff --git a/src/codecs/gif.ts b/src/codecs/gif.ts index 22bb40a3..97a0ec66 100644 --- a/src/codecs/gif.ts +++ b/src/codecs/gif.ts @@ -72,7 +72,7 @@ export interface GIFPalette { * Encodes frames into a GIF. `loopCount`: Infinity loops forever, 0 plays once (no loop extension), n repeats n times; * `comments` are written as comment extensions */ -export function encode({ width, height, loopCount = Infinity, frames }: Omit & { loopCount?: number }, palette: GIFPalette, comments: Uint8Array[] = []): Uint8Array { +export function encode({ width, height, loopCount = Infinity, frames }: Omit & { loopCount?: number }, palette: GIFPalette, comments: Uint8Array[] = []): Uint8Array { // frames that restore the background have a transparent index: decoders like Pillow and ffmpeg restore to it // (browsers to transparent), otherwise to the background color. So does the first frame then: Pillow decides by it // whether the animation has transparency at all diff --git a/src/codecs/jpeg.ts b/src/codecs/jpeg.ts index a05ec45d..ca115ec7 100644 --- a/src/codecs/jpeg.ts +++ b/src/codecs/jpeg.ts @@ -997,7 +997,7 @@ export interface EncodeMetadata { subsampling?: '4:4:4' | '4:2:0'; } -export function encode(pixels: Uint8Array, width: number, height: number, quality = 90, { exif, comment, segments, gray = false, fullChroma = false, subsampling }: EncodeMetadata = {}): Uint8Array { +export function encode(pixels: Uint8Array, width: number, height: number, quality = 90, { exif, comment, segments, gray = false, fullChroma = false, subsampling }: 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))); diff --git a/src/codecs/jxl.ts b/src/codecs/jxl.ts index ae096785..bee679e7 100644 --- a/src/codecs/jxl.ts +++ b/src/codecs/jxl.ts @@ -34,8 +34,8 @@ export function isJXL(d: Uint8Array): boolean { * Encodes RGBA pixels as a JPEG XL, lossless or lossy (with a Butteraugli distance above 0, and an effort 0-2): the * codestream, in the container if there is Exif data (a TIFF header and IFDs); lossy with up to threads worker threads */ -export async function encode(rgba: Uint8Array, width: number, height: number, exif?: Uint8Array, distance = 0, effort = 1, threads = 1, gainMap?: GainMapInput): Promise { - const codestreamOf = (pixels: Uint8Array, w: number, h: number, d: number): Promise | Uint8Array => d > 0 ? encodeVarDCT(pixels, w, h, { distance: d, effort, threads }) +export async function encode(rgba: Uint8Array, width: number, height: number, exif?: Uint8Array, distance = 0, effort = 1, threads = 1, gainMap?: GainMapInput): Promise> { + const codestreamOf = (pixels: Uint8Array, w: number, h: number, d: number): Promise> | Uint8Array => d > 0 ? encodeVarDCT(pixels, w, h, { distance: d, effort, threads }) : effort ? encodeModular(pixels, w, h, threads) : encodeRGBA(pixels, w, h); const stream = await codestreamOf(rgba, width, height, distance); if (!exif?.length && !gainMap) return stream; diff --git a/src/codecs/jxl/fast-lossless.ts b/src/codecs/jxl/fast-lossless.ts index d0b2e224..8e260c3c 100644 --- a/src/codecs/jxl/fast-lossless.ts +++ b/src/codecs/jxl/fast-lossless.ts @@ -511,7 +511,7 @@ function dcGlobalCommon(w: BitWriter, codes: PrefixCode[]): void { * Encodes RGBA pixels as a lossless JPEG XL codestream: grey if every pixel is, with alpha if one isn't opaque; byte for * byte what cjxl -e 1 writes on CPUs with AVX2 or NEON (libjxl's chunks of 16 pixels) */ -export function encodeRGBA(rgba: Uint8Array, width: number, height: number): Uint8Array { +export function encodeRGBA(rgba: Uint8Array, width: number, height: number): Uint8Array { if (width < 1 || height < 1) throw new RangeError('JPEG XL: dimensions must be at least 1'); const n = width * height; let grey = true, opaque = true; @@ -534,7 +534,7 @@ export function encodeRGBA(rgba: Uint8Array, width: number, height: number): Uin } /** Encodes 8-bit samples of 1-4 channels (grey, grey and alpha, RGB, RGBA) as a JPEG XL codestream */ -export function encode(px: Uint8Array, width: number, height: number, numChans: number, chunkSize = 16): Uint8Array { +export function encode(px: Uint8Array, width: number, height: number, numChans: number, chunkSize = 16): Uint8Array { const stride = width * numChans; // ---- a palette, if the colors (with black) are at most 512 and their hashes don't collide const palette = new Uint32Array(HASH_SIZE), lookup = new Int16Array(HASH_SIZE); diff --git a/src/codecs/jxl/modular-lossless.ts b/src/codecs/jxl/modular-lossless.ts index f9c9ba4e..b95207ae 100644 --- a/src/codecs/jxl/modular-lossless.ts +++ b/src/codecs/jxl/modular-lossless.ts @@ -216,7 +216,7 @@ const MIN_THREADED_PIXELS = 1 << 19, PIXELS_PER_THREAD = 1 << 17; * opaque; a palette of up to 1024 colors, else palettes of the channels using few of their values, YCoCg, the residuals * of the weighted predictor in the contexts of its error (groups in up to threads worker threads) */ -export async function encodeModular(rgba: Uint8Array, width: number, height: number, threads = 1): Promise { +export async function encodeModular(rgba: Uint8Array, width: number, height: number, threads = 1): Promise> { if (width < 1 || height < 1) throw new RangeError('JPEG XL: dimensions must be at least 1'); const n = width * height; let grey = true, opaque = true; diff --git a/src/codecs/jxl/vardct-enc.ts b/src/codecs/jxl/vardct-enc.ts index ffc5adee..294e09df 100644 --- a/src/codecs/jxl/vardct-enc.ts +++ b/src/codecs/jxl/vardct-enc.ts @@ -316,7 +316,7 @@ export function hfTokenChunks({ types, hf, pw, bw, bh, groupsPerRow }: HFTokenTa } /** Encodes RGBA pixels as a lossy JPEG XL codestream, alpha (if the image has transparency) losslessly */ -export async function encodeVarDCT(rgba: Uint8Array, width: number, height: number, options: VarDCTOptions): Promise { +export async function encodeVarDCT(rgba: Uint8Array, width: number, height: number, options: VarDCTOptions): Promise> { if (width < 1 || height < 1) throw new RangeError('JPEG XL: dimensions must be at least 1'); // threads for the XYB planes, the quantization field, the varblock search, the coefficients and their tokens, if // they start (the same files). Started first, they load while the work begins here @@ -329,7 +329,7 @@ export async function encodeVarDCT(rgba: Uint8Array, width: number, height: numb } } -async function encode(rgba: Uint8Array, width: number, height: number, { distance, effort }: VarDCTOptions, pool: { threads: Thread[] | null }): Promise { +async function encode(rgba: Uint8Array, width: number, height: number, { distance, effort }: VarDCTOptions, pool: { threads: Thread[] | null }): Promise> { const bw = Math.ceil(width / 8), bh = Math.ceil(height / 8), pw = bw * 8; // (a stage in the threads; if they fail, like workers whose module can't be loaded, it and the rest run here) const parallel = async (run: (threads: Thread[] | null) => Promise): Promise => { diff --git a/src/codecs/netpbm.ts b/src/codecs/netpbm.ts index 5fd8df64..038b158e 100644 --- a/src/codecs/netpbm.ts +++ b/src/codecs/netpbm.ts @@ -168,7 +168,7 @@ function decodePFM(d: Uint8Array, channels: number, maxPixels: number, toneMappi * Encodes RGBA pixels into the smallest fitting Netpbm format: PBM for black and white images, PGM for grayscale * ones, PPM, and PAM (grayscale or RGB with alpha) for images with transparency */ -export function encode(rgba: Uint8Array, width: number, height: number): Uint8Array { +export function encode(rgba: Uint8Array, width: number, height: number): Uint8Array { if (width < 1 || height < 1) throw new RangeError('Netpbm: dimensions must be at least 1'); const count = width * height; let opaque = true, gray = true, bitmap = true; diff --git a/src/codecs/png.ts b/src/codecs/png.ts index d60b634a..8faf0a82 100644 --- a/src/codecs/png.ts +++ b/src/codecs/png.ts @@ -69,7 +69,7 @@ const ADAM7 = [[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, * Encodes raw pixel data into a PNG * @param data Raw pixel data, rows packed MSB first for depths below 8 */ -export async function encode(data: Uint8Array, { text, width, height, channels, depth = 8, level = 0, palette, transparency, filter: filterRows = false }: EncodeOptions): Promise { +export async function encode(data: Uint8Array, { text, width, height, channels, depth = 8, level = 0, palette, transparency, filter: filterRows = false }: EncodeOptions): Promise> { const header = new Uint8Array(13); const view = new DataView(header.buffer); view.setUint32(0, width); @@ -343,7 +343,7 @@ const aligned = (rgba: Uint8Array): Uint8Array => rgba.byteOffset % 4 ? rgba.sli * Rows of non-palette images are paeth filtered where that compresses better (unless the data isn't compressed), * palette indices aren't filtered (like libpng recommends) */ -export async function encodeRGBA(rgba: Uint8Array, width: number, height: number, level: number, text?: EncodeOptions['text']): Promise { +export async function encodeRGBA(rgba: Uint8Array, width: number, height: number, level: number, text?: EncodeOptions['text']): Promise> { rgba = aligned(rgba.subarray(0, width * height * 4)); const format = pixelFormat([rgba]); const { channels, depth, palette, transparency } = format; @@ -372,7 +372,7 @@ export interface APNGEncodeFrame { * @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, text?: EncodeOptions['text']): 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'); frames = [coverCanvas(frames[0], width, height), ...frames.slice(1)]; @@ -415,7 +415,7 @@ export function frameData(rgba: Uint8Array, width: number, height: number, forma } /** An APNG from its compressed frames, all in one pixel format */ -export function animationFile(frames: EncodedAPNGFrame[], width: number, height: number, plays: number, format: PixelFormat, text?: EncodeOptions['text']): Uint8Array { +export function animationFile(frames: EncodedAPNGFrame[], width: number, height: number, plays: number, format: PixelFormat, text?: EncodeOptions['text']): Uint8Array { const { channels, depth, palette, transparency } = format; const header = new Uint8Array(13); const hv = new DataView(header.buffer); diff --git a/src/codecs/qoi.ts b/src/codecs/qoi.ts index e5cc922c..3f506ae9 100644 --- a/src/codecs/qoi.ts +++ b/src/codecs/qoi.ts @@ -62,7 +62,7 @@ export function decode(d: Uint8Array, maxPixels = MAX_PIXELS): { width: number, } /** Encodes RGBA pixels into a QOI (3 channels if the image is opaque), byte for byte like the reference encoder */ -export function encode(rgba: Uint8Array, width: number, height: number): Uint8Array { +export function encode(rgba: Uint8Array, width: number, height: number): Uint8Array { if (width < 1 || height < 1) throw new RangeError('QOI: dimensions must be at least 1'); const count = width * height; let opaque = true; diff --git a/src/codecs/tga.ts b/src/codecs/tga.ts index cc7c6949..90103239 100644 --- a/src/codecs/tga.ts +++ b/src/codecs/tga.ts @@ -208,7 +208,7 @@ function extensionArea({ author, comment, time, software }: Extension, alpha: bo return area; } -export function encode(rgba: Uint8Array, width: number, height: number, extension?: Extension): Uint8Array { +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; diff --git a/src/codecs/vp8l.ts b/src/codecs/vp8l.ts index ec31f22a..f5077c1a 100644 --- a/src/codecs/vp8l.ts +++ b/src/codecs/vp8l.ts @@ -570,7 +570,7 @@ class BitWriter extends ByteWriter { } } - override finish(): Uint8Array { + override finish(): Uint8Array { if (this.count > 0) this.write(0, 8 - this.count); return super.finish(); } diff --git a/src/codecs/webp.ts b/src/codecs/webp.ts index e45b4bd5..10af5c24 100644 --- a/src/codecs/webp.ts +++ b/src/codecs/webp.ts @@ -302,7 +302,7 @@ function concat(parts: Uint8Array[]): Uint8Array { return out; } -function riff(chunks: Uint8Array[]): Uint8Array { +function riff(chunks: Uint8Array[]): Uint8Array { let size = 4; for (const c of chunks) size += c.length; const out = new Uint8Array(8 + size); @@ -340,7 +340,7 @@ const MIN_THREADED_PIXELS = 1536 << 10, PIXELS_PER_THREAD = 512 << 10; * @param exif Exif data (a TIFF header and IFDs), needs the extended format (VP8X) * @param effort 0 fast, 1 in between, 2 (and above) the most compression */ -export async function encode(rgba: Uint8Array, width: number, height: number, exif?: Uint8Array, threads = 1, effort = 2): Promise { +export async function encode(rgba: Uint8Array, width: number, height: number, exif?: Uint8Array, threads = 1, effort = 2): Promise> { const n = Math.min(threads, Math.floor(width * height / PIXELS_PER_THREAD)); const pool = n > 1 && width * height >= MIN_THREADED_PIXELS ? startThreads(n) : null; let stream: Uint8Array; @@ -392,7 +392,7 @@ export function lossyOptions(quality: number | undefined, effort: number, sharp: * isn't opaque) * @param exif Exif data (a TIFF header and IFDs), needs the extended format (VP8X) */ -export async function encodeLossy(rgba: Uint8Array, width: number, height: number, lossy: LossyOptions, exif?: Uint8Array): Promise { +export async function encodeLossy(rgba: Uint8Array, width: number, height: number, lossy: LossyOptions, exif?: Uint8Array): Promise> { const { alpha, chunks } = await lossyChunks(rgba, width, height, lossy); if (!alpha && !exif) return riff(chunks); @@ -449,7 +449,7 @@ export function lossyTolerance(quality: number): number { * @param lossy For lossy frames * @param effort For lossless frames: 0 fast, 1 in between, 2 (and above) the most compression */ -export async function encodeAnimation(frames: WebPEncodeFrame[], width: number, height: number, plays: number, exif?: Uint8Array, lossy?: LossyOptions, effort = 2): Promise { +export async function encodeAnimation(frames: WebPEncodeFrame[], width: number, height: number, plays: number, exif?: Uint8Array, lossy?: LossyOptions, effort = 2): Promise> { 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'); @@ -487,7 +487,7 @@ export async function frameChunk(f: WebPEncodeFrame, lossy?: LossyOptions, effor } /** An animation's file from its frames' ANMF chunks */ -export function animationFile(frames: Uint8Array[], width: number, height: number, plays: number, alpha: boolean, exif?: Uint8Array): Uint8Array { +export function animationFile(frames: Uint8Array[], width: number, height: number, plays: number, alpha: boolean, exif?: Uint8Array): Uint8Array { const vp8x = new Uint8Array(10); vp8x[0] = 2 | (alpha ? 16 : 0) | (exif ? 8 : 0); // animation, alpha, exif set24(vp8x, 4, width - 1); diff --git a/src/util/bytes.ts b/src/util/bytes.ts index bdba84f2..5e2ef119 100644 --- a/src/util/bytes.ts +++ b/src/util/bytes.ts @@ -44,7 +44,7 @@ export class ByteWriter { } /** the written bytes */ - finish(): Uint8Array { + finish(): Uint8Array { return this.buffer.slice(0, this.length); } } diff --git a/src/util/mem.ts b/src/util/mem.ts index e030506c..fd67079d 100644 --- a/src/util/mem.ts +++ b/src/util/mem.ts @@ -17,7 +17,7 @@ export function view(buffer: BinaryData): Uint8Array { /** * Concatenates byte arrays */ -export function concat(parts: ArrayLike[]): Uint8Array { +export function concat(parts: ArrayLike[]): Uint8Array { let length = 0; for (const part of parts) length += part.length; From 53d62d30c0bf795bf03f887e6866f385228fa465 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Mon, 9 Nov 2026 19:42:12 +0100 Subject: [PATCH 072/188] feat: test runner parallelism --- tests/run.js | 65 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/tests/run.js b/tests/run.js index 174a0527..d19cc653 100644 --- a/tests/run.js +++ b/tests/run.js @@ -1,40 +1,75 @@ const fs = require('fs'); const child_process = require('child_process'); +const os = require('os'); // runs every test with the runtime running this script: node, deno or bun const runtime = `"${process.execPath}"`; const command = typeof Deno !== 'undefined' ? `${runtime} run -A` : typeof Bun !== 'undefined' ? runtime : `${runtime} --unhandled-rejections=strict`; +// Determine safe max concurrency based on available CPU cores +const concurrencyLimit = os.availableParallelism ? os.availableParallelism() : (os.cpus()?.length || 4); + (async () => { - for (const file of fs.readdirSync('./tests/')) { - if (file === 'run.js' || file.slice(-3) !== '.js') continue; - - console.log(`running test ${file}`); - const start = Date.now(); - const proc = child_process.exec(`${command} ./tests/${file}`); - proc.stderr.pipe(process.stderr); - proc.stdout.pipe(process.stdout); - await new Promise(resolve => { + const testFiles = fs.readdirSync('./tests/').filter(file => file !== 'run.js' && file.slice(-3) === '.js'); + + const runTest = (file) => { + return new Promise((resolve) => { + console.log(`[started] test ${file}`); + const start = Date.now(); + const proc = child_process.exec(`${command} ./tests/${file}`); + + // Buffer outputs to prevent interleaving + let stdoutData = ''; + let stderrData = ''; + + proc.stdout.on('data', chunk => { stdoutData += chunk; }); + proc.stderr.on('data', chunk => { stderrData += chunk; }); + let timedOut = false; const timeout = setTimeout(() => { - console.log('script timeout'); timedOut = true; proc.kill('SIGTERM'); }, 60000); proc.on('exit', code => { clearTimeout(timeout); + const duration = Date.now() - start; + + // Print the buffered output atomically once the test finishes + if (stdoutData) process.stdout.write(stdoutData); + if (stderrData) process.stderr.write(stderrData); if (code !== 0 || timedOut) { - console.error(`test ${file} failed in ${Date.now() - start}ms`); - process.exit(1); + if (timedOut) console.error(`script timeout: ${file}`); + console.error(`test ${file} failed in ${duration}ms`); + resolve(false); } else { - console.log(`test ${file} passed in ${Date.now() - start}ms`); - resolve(); + console.log(`test ${file} passed in ${duration}ms`); + resolve(true); } }); }); + }; + + // Concurrency pool runner to cap active tests at concurrencyLimit + let index = 0; + const results = []; + async function worker() { + while (index < testFiles.length) { + const currentIndex = index++; + const file = testFiles[currentIndex]; + results[currentIndex] = await runTest(file); + } } - console.log('all tests passed'); + console.log(`Running tests with a max concurrency of ${concurrencyLimit}...`); + const workers = Array(Math.min(concurrencyLimit, testFiles.length)).fill(null).map(() => worker()); + await Promise.all(workers); + + if (results.every(Boolean)) { + console.log('all tests passed'); + } else { + console.error('some tests failed'); + process.exit(1); + } })(); \ No newline at end of file From 784ae832e8d10c8b68e69a925cc5f98044fccd90 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Mon, 9 Nov 2026 19:54:44 +0100 Subject: [PATCH 073/188] fix: increase timeout to 2min per test --- tests/run.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/run.js b/tests/run.js index d19cc653..40259065 100644 --- a/tests/run.js +++ b/tests/run.js @@ -29,7 +29,7 @@ const concurrencyLimit = os.availableParallelism ? os.availableParallelism() : ( const timeout = setTimeout(() => { timedOut = true; proc.kill('SIGTERM'); - }, 60000); + }, 1000 * 60 * 2); proc.on('exit', code => { clearTimeout(timeout); From e0782ce8b195bf186da7b653ed7d6ede3fb35086 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Mon, 9 Nov 2026 20:45:27 +0100 Subject: [PATCH 074/188] Consolidate resize fit modes --- CHANGELOG.md | 16 +++---- guides/Migrating to 4.0.md | 42 ++++++++++------- src/animation.ts | 53 ++++++++++----------- src/frame.ts | 34 +++----------- src/image.ts | 95 +++++++++++++++++++++----------------- src/index.ts | 2 +- tests/api-shapes.js | 24 +++++----- tests/gainmap.js | 2 +- tests/options-only.js | 8 ++-- tests/regressions.js | 8 ++-- 10 files changed, 139 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b5d8ca9..c51d5a69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **`Image.encode()` is removed**: use `image.encodePNG()`, which takes the same arguments. +- **`contain()` and `cover()` are removed** from `Image`, `Frame` and `Animation`: use `resize({ width, height, fit: 'contain' | 'cover' })`. `fit: 'contain'` also takes `background`; `ResizeFit` names the fit modes and `ContainOptions` is removed. 3.x's `contain()` maps to `'inside'`, and 3.x's padded `fit()` maps to `'contain'`. + - **The color helpers of `Image` are removed**: `Image.rgbaToColor()`, `Image.rgbToColor()`, `Image.hslaToColor()`, `Image.hslToColor()`, `Image.rgbaToHSLA()`, `Image.colorToRGBA()`, `Image.colorToRGB()` and `Image.gradient()` are now `Colors.fromRGBA()`, `Colors.fromRGB()`, `Colors.fromHSLA()`, `Colors.fromHSL()`, `Colors.toHSLA()`, `Colors.toRGBA()`, `Colors.toRGB()` and `Colors.gradient()`. - **Iterating over an image and `iterateWithColors()` are removed**: `for (const [x, y] of image)` is now `image.coordinates()`, and `image.iterateWithColors()` is `image.pixels()`, which yields `[x, y, color]`. @@ -26,7 +28,7 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **The deprecated animation encoder option types and `AnimationEncoder.frames` are removed**: `AnimationEncoderOptions`, `WebPAnimationEncoderOptions`, `GIFAnimationEncoderOptions` and `APNGAnimationEncoderOptions` are `AnimationEncodeOptions`, `WebPAnimationEncodeOptions`, `GIFAnimationEncodeOptions` and `APNGEncodeOptions`, each with `AnimationOptions` for the `loopCount`, and `encoder.frames` is `encoder.frameCount`. -- **The positional mode of `scale()`, `resize()`, `contain()` and `cover()` is removed**, on `Image`, `Frame` and `Animation`: `image.resize(width, height, 'area')` and `image.resize(width, height, ResizeModes.AREA)` are now `image.resize(width, height, { mode: 'area' })`, and `image.scale(0.5, { mode })` takes the mode in the same way. Other values in the position throw a `TypeError`, and `fast` goes in the same object. +- **The positional mode of `scale()` and `resize()` is removed**, on `Image`, `Frame` and `Animation`: `image.resize(width, height, 'area')` and `image.resize(width, height, ResizeModes.AREA)` are now `image.resize(width, height, { mode: 'area' })`, and `image.scale(0.5, { mode })` takes the mode in the same way. Other values in the position throw a `TypeError`, and `fast` goes in the same object. - **The positional arguments of `composite()`, `rotate()`, `sharpen()`, `dominantColor()`, `roundCorners()`, `fisheye()`, `trim()` and `colorToAlpha()` are removed**: the values go in the options object, which is the only second argument now. `image.composite(logo, 10, 20, { opacity: 0.5 })` is `image.composite(logo, { x: 10, y: 20, opacity: 0.5 })`, `image.sharpen(2, 3)` is `image.sharpen({ amount: 2, radius: 3 })`, `image.rotate(45, false)` is `image.rotate(-45, { expand: false })` (see the clockwise rotation below), `image.dominantColor(true, true, 15)` is `image.dominantColor({ ignoreBlack: true, ignoreWhite: true, threshold: 15 })`, `image.roundCorners(8)` is `image.roundCorners({ radius: 8 })` and `image.fisheye(3)` is `image.fisheye({ strength: 3 })`. `trim(color, options)` and `colorToAlpha(color, options)` take the color as `color` in the options (`trim({ color, tolerance })`, `colorToAlpha({ color, transparencyThreshold })`), and `Frame.trim()` works the same way. A value in the old position throws a `TypeError`. @@ -54,9 +56,7 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **Loop counts a format can't store throw a `RangeError`**: a GIF stores up to 65535 loops, a WebP up to 65534 and an APNG up to 2147483646 (2^31 - 2). A larger count used to wrap around silently (65536 as a GIF was forever). The encoders and the streaming encoders refuse it before encoding; `Infinity` is always fine. -- **Removed and narrowed exported types**: `TextOptions` is `RenderTextOptions`, `FrameDisposalModeId` is removed (`FrameDisposalMode` has the names), `AdjustOptions` is removed with `absolute`, `PNGMetadata` is `Metadata`, and `AnimationEncoderOptions`, `WebPAnimationEncoderOptions`, `GIFAnimationEncoderOptions` and `APNGAnimationEncoderOptions` are `AnimationEncodeOptions`, `WebPAnimationEncodeOptions`, `GIFAnimationEncodeOptions` and `APNGEncodeOptions`. `ResizeMode` is narrowed to the lower-case names (`'area'`, ...), and `SVGScaleMode` is removed. - -- **`contain()` pads to the box, `fit()` is removed**: `image.contain(w, h)` resizes the image to fit inside the box and centers it on a canvas of exactly that size, like CSS' `object-fit: contain` (what `fit()` did), with transparent bars or a `background` color. The resize without bars, which `contain()` did, is `resize({ width, height, fit: 'inside' })`. +- **Removed and narrowed exported types**: `TextOptions` is `RenderTextOptions`, `FrameDisposalModeId` is removed (`FrameDisposalMode` has the names), `AdjustOptions` is removed with `absolute`, `PNGMetadata` is `Metadata`, and `AnimationEncoderOptions`, `WebPAnimationEncoderOptions`, `GIFAnimationEncoderOptions` and `APNGAnimationEncoderOptions` are `AnimationEncodeOptions`, `WebPAnimationEncodeOptions`, `GIFAnimationEncodeOptions` and `APNGEncodeOptions`. `ResizeMode` is narrowed to the lower-case names (`'area'`, ...), `ContainOptions` is removed, and `SVGScaleMode` is removed. - **Hues are in degrees**: `Colors.fromHSL()`, `Colors.fromHSLA()` and `Colors.toHSLA()` take and return the hue in degrees (0-360, like CSS' `hsl()` and `hueShift()`) instead of 0-1. @@ -68,7 +68,7 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **`encodeJPEG()` composites transparent pixels onto a background**, black by default (`background` sets the color), instead of dropping the alpha and showing the stored color. Opaque images encode as before. -- **`Frame` holds an image instead of extending `Image`**: `frame.image` is the image, `new Frame(image, options)` takes it (without a copy), and `Frame.from()` is removed. The methods that change the size (`resize()`, `scale()`, `contain()`, `cover()`, `crop()`, `trim()`, `rotate()`, `transform()`, `dropShadow()` and `outline()`) stay on the frame and move its offsets so it stays in place; everything else is `frame.image.blur(2)` and the like. `frame.crop()` and `frame.rotate()` now move the offsets too (3.x kept them), `frame.cover()` moves them by the cropped amount, and the offsets can become negative where 3.x stopped the growth at 0. +- **`Frame` holds an image instead of extending `Image`**: `frame.image` is the image, `new Frame(image, options)` takes it (without a copy), and `Frame.from()` is removed. `resize()` (including its `'contain'` and `'cover'` fit modes), `scale()`, `crop()`, `trim()`, `rotate()`, `transform()`, `dropShadow()` and `outline()` stay on the frame and move its offsets so it stays in place; everything else is `frame.image.blur(2)` and the like. `frame.crop()` and `frame.rotate()` now move the offsets too (3.x kept them), and the offsets can become negative where 3.x stopped the growth at 0. - **GIF palettes take a color count**: the `quality` of `Animation.encodeGIF()` and `Animation.gifEncoder()` is `colors` (2-256, default 256, which `quality` 90 and above gave), with the new `dither` and `ditherStrength`. Their options type is `GIFAnimationEncodeOptions`; `GIFEncodeOptions` is now the options of `Image.encodeGIF()`. @@ -90,12 +90,12 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation ### New -- `Animation.clone()`, `Animation.crop()`, `Animation.contain()` and `Animation.cover()`, working on the canvas like their `Image` counterparts (`contain()` with a `background` makes full canvas frames of the composited animation). -- `resize({ width, height, fit: 'inside' })` on images, frames and animations: as large as fits inside the box, keeping the aspect ratio. +- `Animation.clone()` and `Animation.crop()`, plus `'contain'` and `'cover'` fit modes of `Animation.resize()`. +- `resize({ width, height, fit })` on images, frames and animations: `'inside'` fits without padding, `'contain'` pads, and `'cover'` crops while keeping the aspect ratio; `background` sets the bars for `'contain'`. - `encodeJPEG({ subsampling: '4:4:4' | '4:2:0' })`; the default stays 4:2:0 up to quality 90 and 4:4:4 above. - `Colors.gradient()` takes an array of evenly spaced colors, like the `stops` of the gradient functions. - `Animation.scale()`, `Animation.trim()`, `Animation.rotate()`, `Animation.transform()`, `Animation.dropShadow()` and `Animation.outline()`. `scale()` and `trim()` keep the frames; the others work on the composited animation (full canvas frames) and resize the canvas. -- `Frame.contain()`, `Frame.cover()`, `Frame.rotate()`, `Frame.transform()`, `Frame.dropShadow()` and `Frame.outline()`, which move the offsets like `Frame.resize()` and `Frame.crop()`. +- `'contain'` and `'cover'` fit modes of `Frame.resize()`, plus `Frame.rotate()`, `Frame.transform()`, `Frame.dropShadow()` and `Frame.outline()`, which move the offsets like `Frame.resize()` and `Frame.crop()`. - `Image.encodeGIF()` for single images, with `colors`, `dither` and `ditherStrength` (also on the animation encoders), and the `DitherMethod` type that `quantize()` and the GIF encoders share. - `renderSVG({ fonts })` takes a single font without an array. diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index eb74dff6..8f797fa2 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -109,17 +109,25 @@ forever.) `Infinity` is always fine. Search your code for: `loopCount: -1`, `loopCount = -1`, `loopCount === -1` and `loopCount < 0`. -### `contain()` pads to the box +### Resize fit modes -`image.contain(width, height)` now works like CSS' `object-fit: contain`: the image is resized to fit inside the box -and centered on a canvas of exactly that size, with transparent bars (or the `background` color). That is what -`fit()` did in 3.x; `fit()` is removed. 3.x's `contain()`, which only resized and could return a smaller image, is -`resize({ width, height, fit: 'inside' })`. +`resize({ width, height, fit })` handles stretching, aspect-ratio fitting and cropping. It works on images, frames and +animations; frame offsets and animation canvases follow the resized pixels. + +| `fit` | Result | +| --- | --- | +| `'fill'` (default) | Stretches to the exact dimensions | +| `'inside'` | Keeps the aspect ratio and fits inside the box without padding | +| `'contain'` | Keeps the aspect ratio and pads to the exact box; `background` sets the bar color | +| `'cover'` | Keeps the aspect ratio and crops to the exact box | + +`contain` and `cover` require both dimensions. `background` is only valid with `fit: 'contain'`. | 3.x | 4.0 | | --- | --- | | `image.contain(800, 600)` (at most 800x600, no bars) | `image.resize({ width: 800, height: 600, fit: 'inside' })` | -| `image.fit(800, 600)` (exactly 800x600, transparent bars) | `image.contain(800, 600)`, or `image.contain(800, 600, { background: 0x000000ff })` for black bars | +| `image.fit(800, 600)` (exactly 800x600, transparent bars) | `image.resize({ width: 800, height: 600, fit: 'contain' })` | +| `image.cover(800, 600)` | `image.resize({ width: 800, height: 600, fit: 'cover' })` | ### Hues are in degrees @@ -170,13 +178,13 @@ Search your code for: `.quantize(`. ### Frames move their offsets when their size changes -A frame's `crop()`, `cover()` and `rotate()` now move its offsets, so the frame's pixels stay where they are in the -animation. In 3.x, `crop()` and `rotate()` kept the offsets (the top left corner stayed in place), and `cover()` only -scaled them. Now: +A frame's `crop()`, `resize({ fit: 'cover' })` and `rotate()` now move its offsets, so the frame's pixels stay where +they are in the animation. In 3.x, `crop()` and `rotate()` kept the offsets (the top left corner stayed in place), and +`cover()` only scaled them. Now: - `frame.crop(x, y, w, h)` adds `x` and `y` to the offsets, like `trim()` already did. - `frame.rotate(angle)` keeps the frame's middle in place. -- `frame.cover(w, h)` scales the offsets and then moves them by the cropped amount. +- `frame.resize({ width: w, height: h, fit: 'cover' })` scales the offsets and then moves them by the cropped amount. `transform()`, `dropShadow()` and `outline()` moved the offsets in 3.x too, but only as far as they allowed: a frame at `xOffset: 0` couldn't grow to the left, so a shadow or outline was cut off on that side and a transform grew to the @@ -186,7 +194,7 @@ animation's canvas isn't encoded. To keep a frame's offsets as they are, call the method on its image: `frame.image.crop(x, y, w, h)`. See [frames hold an image](#frames-hold-an-image) for the other frame changes. -Search your code for: `.crop(`, `.cover(` and `.rotate(` on frames. +Search your code for: `.crop(`, `.resize(` with `fit: 'cover'`, and `.rotate(` on frames. ## Removed APIs @@ -326,7 +334,7 @@ object under the names of their options. The options of each method are on its o | `image.resize(256, 256, 'bicubic')`, `image.resize(256, 256, ResizeModes.AREA)` | `image.resize(256, 256, { mode: 'bicubic' })` | | `image.scale(0.5, 'area')`, `image.scale(0.5, ResizeModes.AREA)` | `image.scale(0.5, { mode: 'area' })` | | `image.resize(100)` (width only, the height left out) | `image.resize({ width: 100 })`, which keeps the aspect ratio. The positional form throws a `RangeError` without a height | -| `image.contain(w, h, mode)`, `image.fit(w, h, mode)`, `image.cover(w, h, mode)` | `image.resize({ width: w, height: h, fit: 'inside', mode })`, `image.contain(w, h, { mode })` and `image.cover(w, h, { mode })`, see [`contain()` pads to the box](#contain-pads-to-the-box) | +| `image.contain(w, h, mode)`, `image.fit(w, h, mode)`, `image.cover(w, h, mode)` | `image.resize({ width: w, height: h, fit: 'inside', mode })`, with `'contain'` or `'cover'` as needed; see [Resize fit modes](#resize-fit-modes) | | `Frame` and `Animation`: `resize(w, h, mode)` | `resize(w, h, { mode })` | | `image.composite(logo, 10, 20, { opacity: 0.5 })` | `image.composite(logo, { x: 10, y: 20, opacity: 0.5 })` | | `image.rotate(45, false)` | `image.rotate(-45, { expand: false })`: the angle is negated because [`rotate()` turns clockwise](#rotate-turns-clockwise) now | @@ -401,9 +409,9 @@ A `Frame` is no longer an `Image`: it holds one, `frame.image`, with the frame's | `image.composite(frame)`, `frame.encodePNG()` | `image.composite(frame.image)`, `frame.image.encodePNG()` | | `frame instanceof Image` | `false` now | -The methods that change the size stay on the frame: `resize()`, `scale()`, `contain()`, `cover()`, `crop()`, `trim()`, -`rotate()`, `transform()`, `dropShadow()` and `outline()`. They move the offsets so the frame stays in place in the -animation (resizing scales the offsets, as if the whole animation was resized); see +The methods that change the size stay on the frame: `resize()` (including its `'contain'` and `'cover'` fit modes), +`scale()`, `crop()`, `trim()`, `rotate()`, `transform()`, `dropShadow()` and `outline()`. They move the offsets so the +frame stays in place in the animation (resizing scales the offsets, as if the whole animation was resized); see [frames move their offsets](#frames-move-their-offsets-when-their-size-changes) for how that differs from 3.x. `width`, `height`, `clone()` and `toString()` work as before. Everything else goes through `frame.image`. The image's own methods don't know about the frame and leave the offsets alone: after `frame.image.rotate(90)`, the frame's top @@ -417,7 +425,7 @@ Values that were only checked when they were used are now checked when they are throws a `RangeError` (a `TypeError` for `useBaseColorSpace`) and the old value stays. An `Animation` now has a canvas size of its own: `animation.width` and `animation.height` are the decoded file's, the -one given as `new Animation(frames, { width, height })`, or the one `resize()`, `crop()`, `contain()` and `cover()` set. +one given as `new Animation(frames, { width, height })`, or the one `resize()` and `crop()` set. Without one, they are the frames' right and bottom edge, as in 3.x. Frames, or the parts of them, outside the canvas aren't encoded. @@ -492,5 +500,5 @@ Also search for the variables that hold animations, and for `[0]`, `.map(`, `.fi search can't tell an animation from an array, so check the type of each receiver. Then compile with `tsc`, and run the tests that cover your decode and encode paths. The nine behavior changes need -runtime checks: a file with several frames, a one-frame GIF, any `loopCount: -1`, every `contain()`, every HSL hue, +runtime checks: a file with several frames, a one-frame GIF, any `loopCount: -1`, every resize fit mode, every HSL hue, every `rotate()`, every JPEG made from an image with transparency, every `quantize()`, and the size changes of frames. diff --git a/src/animation.ts b/src/animation.ts index 305701b8..9cc10dc2 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -6,7 +6,7 @@ import * as jxl from './codecs/jxl.ts'; import { availableThreads } from './util/threads.ts'; import { BinaryData, view } from './util/mem.ts'; import { GainMap } from './gainmap.ts'; -import { Image, DecodeOptions, EncodeOptions, GIFEncodeOptions, ContainOptions, ResizeOptions, ResizeToOptions, RotateOptions, TransformOptions, ShadowOptions, OutlineOptions, TrimOptions, resizeArguments, insideSize } from './image.ts'; +import { Image, DecodeOptions, EncodeOptions, GIFEncodeOptions, ResizeOptions, ResizeToOptions, RotateOptions, TransformOptions, ShadowOptions, OutlineOptions, TrimOptions, resizeArguments, insideSize } from './image.ts'; import { Frame } from './frame.ts'; import { ImageType } from './image-type.ts'; import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts'; @@ -163,8 +163,8 @@ export class Animation { } /** - * The canvas width: the one given to the constructor, the decoded file's, or the one {@link Animation.resize}, - * {@link Animation.crop}, {@link Animation.contain} and {@link Animation.cover} set; else the frames' right edge + * The canvas width: the one given to the constructor, the decoded file's, or the one {@link Animation.resize} and + * {@link Animation.crop} set; else the frames' right edge */ get width(): number { if (this.__canvas__) return this.__canvas__[0]; @@ -216,6 +216,12 @@ export class Animation { resize(options: ResizeToOptions): this; resize(widthOrOptions: number | ResizeToOptions, heightArgument?: number, optionsArgument?: ResizeOptions): this { const [givenWidth, givenHeight, options, fit] = resizeArguments(widthOrOptions, heightArgument, optionsArgument); + if (fit === 'contain' || fit === 'cover') { + if (givenWidth === undefined || givenHeight === undefined) throw new TypeError(`resize({ fit: '${fit}' }) needs both width and height`); + return fit === 'contain' + ? this.__contain__(givenWidth, givenHeight, options) + : this.__cover__(givenWidth, givenHeight, options); + } const originalWidth = this.width, originalHeight = this.height; if (!originalWidth || !originalHeight) throw new RangeError('An animation without frames or canvas can\'t be resized'); // a side left out follows the other one, keeping the aspect ratio @@ -262,16 +268,11 @@ export class Animation { } /** - * Resizes the animation to fit inside the given box, keeping its aspect ratio, and centers it on a canvas of - * exactly that size, like {@link Image.contain}. With a `background` color, the frames become full canvas frames - * (the composited animation) on that color - * @param width The width of the box - * @param height The height of the box - * @param options The background and how to resize, see {@link ContainOptions} + * Resizes and centers the animation in a box of exactly that size, with transparent or colored bars + * @internal */ - contain(width: number, height: number, options?: ContainOptions): this; - contain(width: number, height: number, options?: ContainOptions): this { - checkOptions(options, 'contain(width, height, { background: 0x000000ff })'); + private __contain__(width: number, height: number, options?: ResizeOptions & { background?: number }): this { + checkOptions(options, 'resize({ width, height, fit: \'contain\', background: 0x000000ff })'); const { background = 0x00000000, ...resizeOpts } = options ?? {}; const backgroundColor = checkColor(background, 'background'); const [boxWidth, boxHeight] = canvasSize(Math.floor(finite(width, 'width')), Math.floor(finite(height, 'height'))); @@ -291,6 +292,18 @@ export class Animation { return this; } + /** + * Resizes the animation to cover the box and crops the overflowing edges + * @internal + */ + private __cover__(width: number, height: number, options?: ResizeOptions): this { + checkOptions(options, 'resize({ width, height, fit: \'cover\' })'); + const [boxWidth, boxHeight] = canvasSize(Math.floor(finite(width, 'width')), Math.floor(finite(height, 'height'))); + const scale = Math.max(boxWidth / this.width, boxHeight / this.height); + this.resize(Math.max(boxWidth, Math.round(this.width * scale)), Math.max(boxHeight, Math.round(this.height * scale)), options); + return this.crop((this.width - boxWidth) / 2, (this.height - boxHeight) / 2, boxWidth, boxHeight); + } + /** * Scales the animation by a factor: every frame and its offsets, and the canvas * @param factor The factor @@ -397,22 +410,6 @@ export class Animation { return this; } - /** - * Resizes the animation to cover the given box, keeping its aspect ratio, and crops the overflowing edges, like - * {@link Image.cover}: the canvas gets exactly that size - * @param width The width of the box - * @param height The height of the box - * @param options See {@link ResizeOptions} - */ - cover(width: number, height: number, options?: ResizeOptions): this; - cover(width: number, height: number, options?: ResizeOptions): this { - checkOptions(options, 'cover(width, height, { mode: \'area\' })'); - const [boxWidth, boxHeight] = canvasSize(Math.floor(finite(width, 'width')), Math.floor(finite(height, 'height'))); - const scale = Math.max(boxWidth / this.width, boxHeight / this.height); - this.resize(Math.max(boxWidth, Math.round(this.width * scale)), Math.max(boxHeight, Math.round(this.height * scale)), options); - return this.crop((this.width - boxWidth) / 2, (this.height - boxHeight) / 2, boxWidth, boxHeight); - } - /** * The frames cut to the canvas: frames that lie inside it as they are, the others cropped (copies), and frames * entirely outside as a transparent pixel (they show nothing, their duration counts) diff --git a/src/frame.ts b/src/frame.ts index eb4ee784..b834b7ce 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -1,4 +1,4 @@ -import { Image, ResizeOptions, ResizeToOptions, TrimOptions, ContainOptions, RotateOptions, TransformOptions, ShadowOptions, OutlineOptions } from './image.ts'; +import { Image, ResizeOptions, ResizeToOptions, TrimOptions, RotateOptions, TransformOptions, ShadowOptions, OutlineOptions } from './image.ts'; import { checkOptions, finite } from './util/options.ts'; /** @@ -29,10 +29,11 @@ export interface FrameOptions { /** * A frame of an {@link Animation}: an {@link Image} ({@link Frame.image}) with a duration, offsets and a disposal mode. - * The frame has the methods that change the image's size (resize, scale, contain, cover, crop, trim, rotate, transform, - * dropShadow, outline), which move the offsets along so the frame stays in place in the animation; every other change - * goes to the image (`frame.image.blur(2)`). The image's own size-changing methods leave the offsets as they are. - * Offsets can become negative: the parts of a frame outside the animation's canvas aren't encoded + * The frame has the methods that change the image's size (`resize()` including its `'contain'` and `'cover'` fit + * modes, `scale()`, `crop()`, `trim()`, `rotate()`, `transform()`, `dropShadow()` and `outline()`), which move the + * offsets along so the frame stays in place in the animation; every other change goes to the image + * (`frame.image.blur(2)`). The image's own size-changing methods leave the offsets as they are. Offsets can become + * negative: the parts of a frame outside the animation's canvas aren't encoded */ export class Frame { /** @internal @ignore */ @@ -226,29 +227,6 @@ export class Frame { return this.__tracked__(image => image.scale(factor, options)); } - /** - * Resizes the frame to fit inside the given box and pads it to exactly that size, like {@link Image.contain}. The - * offsets scale with the image and then move by the padding, so the image stays where the scaled frame would be - * @param width The width of the box - * @param height The height of the box - * @param options The background and how to resize, see {@link ContainOptions} - */ - contain(width: number, height: number, options?: ContainOptions): this { - return this.__tracked__(image => image.contain(width, height, options)); - } - - /** - * Resizes the frame to cover the given box and crops the overflowing edges, like {@link Image.cover}. The offsets - * scale with the image and then move by the cropped amount, so the remaining pixels stay where the scaled frame - * had them - * @param width The width of the box - * @param height The height of the box - * @param options See {@link ResizeOptions} - */ - cover(width: number, height: number, options?: ResizeOptions): this { - return this.__tracked__(image => image.cover(width, height, options)); - } - /** * Crops the frame to a region of it, moving the offsets so the remaining pixels stay where they are in the * animation. The region is clipped to the frame diff --git a/src/image.ts b/src/image.ts index 883fc08a..779ad998 100644 --- a/src/image.ts +++ b/src/image.ts @@ -74,31 +74,48 @@ export interface CompositeOptions { mode?: BlendMode; } +/** How {@link Image.resize}, {@link Frame.resize} and {@link Animation.resize} fit an image into a box */ +export type ResizeFit = 'fill' | 'inside' | 'contain' | 'cover'; + /** * The size for {@link Image.resize}, {@link Frame.resize} and {@link Animation.resize}, and how to resize: a side * that is missing follows from the other one, keeping the aspect ratio */ -export interface ResizeToOptions extends ResizeOptions { - /** The new width. Without it, it follows from the height */ - width?: number; - /** The new height. Without it, it follows from the width */ - height?: number; - /** - * How a width and a height together are met: `'fill'` stretches the image to exactly that size, `'inside'` keeps - * the aspect ratio and makes the image as large as fits inside it, so one side can be smaller (a thumbnail of at - * most 800x600: `resize({ width: 800, height: 600, fit: 'inside' })`). For a box of exactly that size, see - * {@link Image.contain} and {@link Image.cover}. Default: `'fill'` - */ - fit?: 'fill' | 'inside'; -} - -/** Options of {@link Image.contain} */ -export interface ContainOptions extends ResizeOptions { - /** The color of the bars around the image where it doesn't fill the box. Default: `0x00000000` (transparent) */ - background?: Color; -} - -/** Options of {@link Image.scale}, {@link Image.resize}, {@link Image.contain} and {@link Image.cover} */ +export type ResizeToOptions = ResizeOptions & ( + | { + /** The new width. Without it, it follows from the height */ + width?: number; + /** The new height. Without it, it follows from the width */ + height?: number; + /** + * `'fill'` stretches to the exact size; `'inside'` keeps the aspect ratio and makes the image as large as fits + * inside it, so one side can be smaller. Default: `'fill'` + */ + fit?: 'fill' | 'inside'; + } + | { + /** The exact width of the box */ + width: number; + /** The exact height of the box */ + height: number; + /** Keeps the aspect ratio and pads to the exact box */ + fit: 'contain'; + /** For `'contain'`, the color of the bars. Default: `0x00000000` (transparent) */ + background?: Color; + } + | { + /** The exact width of the box */ + width: number; + /** The exact height of the box */ + height: number; + /** Keeps the aspect ratio and crops to the exact box */ + fit: 'cover'; + } +); + +type ResizeRuntimeOptions = ResizeOptions & { background?: Color }; + +/** Options of {@link Image.scale} and {@link Image.resize} */ export interface ResizeOptions { /** The resizing mode. Default: `'bilinear'` */ mode?: ResizeMode; @@ -957,6 +974,12 @@ export class Image { resize(options: ResizeToOptions): this; resize(widthOrOptions: number | ResizeToOptions, heightArgument?: number, optionsArgument?: ResizeOptions): this { const [width, height, options, fit] = resizeArguments(widthOrOptions, heightArgument, optionsArgument); + if (fit === 'contain' || fit === 'cover') { + if (width === undefined || height === undefined) throw new TypeError(`resize({ fit: '${fit}' }) needs both width and height`); + return fit === 'contain' + ? this.__contain__(width, height, options) + : this.__cover__(width, height, options); + } const { mode, fast } = resizeOptions(options); const image = this.__resize__(...this.__resized_size__(width, height, fit), mode, fast); this.__gain_map__?.__rescale__(this.width, this.height, image.width, image.height); @@ -965,18 +988,10 @@ export class Image { } /** - * Resizes the image to fit inside the given box, keeping its aspect ratio, and centers it in a box of exactly that - * size (like CSS' `object-fit: contain`): the bars where it doesn't fill the box are transparent or the - * `background` color. To only resize it to fit inside the box, without the bars, use - * `resize({ width, height, fit: 'inside' })` - * @param width The width of the box - * @param height The height of the box - * @param options The background and how to resize, see {@link ContainOptions} - * @returns The resized image + * Resizes and centers the image in a box of exactly that size, with transparent or colored bars + * @internal */ - contain(width: number, height: number, options?: ContainOptions): this; - contain(width: number, height: number, options?: ContainOptions): this { - checkOptions(options, 'contain(width, height, { background: 0x000000ff })'); + private __contain__(width: number, height: number, options?: ResizeRuntimeOptions): this { const { background = 0x00000000, ...resizeOpts } = options ?? {}; const backgroundColor = checkColor(background, 'background'); resizeOptions(resizeOpts); @@ -996,16 +1011,10 @@ export class Image { } /** - * Resizes the image so it covers the given bounding box, cropping the overflowing edges. - * Always returns the exact dimensions of the bounding box. - * @param width The width of the bounding box - * @param height The height of the bounding box - * @param options See {@link ResizeOptions} - * @returns The resized image + * Resizes the image to cover the box, cropping the overflowing edges + * @internal */ - cover(width: number, height: number, options?: ResizeOptions): this; - cover(width: number, height: number, options?: ResizeOptions): this { - checkOptions(options, 'cover(width, height, { mode: \'area\' })'); + private __cover__(width: number, height: number, options?: ResizeOptions): this { const resizeOpts = resizeOptions(options); width = Math.floor(width); height = Math.floor(height); @@ -2870,14 +2879,14 @@ interface HSLA { * The width, height and mode of resize()'s signatures: a side left out of the options is `undefined` * @internal */ -export function resizeArguments(widthOrOptions: number | ResizeToOptions, height?: number, options?: ResizeOptions): [width: number | undefined, height: number | undefined, options: ResizeOptions | undefined, fit: 'fill' | 'inside'] { +export function resizeArguments(widthOrOptions: number | ResizeToOptions, height?: number, options?: ResizeOptions): [width: number | undefined, height: number | undefined, options: ResizeRuntimeOptions | undefined, fit: ResizeFit] { if (!isOptions(widthOrOptions)) { checkOptions(options, 'resize(width, height, { mode: \'area\' })'); return [finite(widthOrOptions, 'width'), finite(height as number, 'height'), options, 'fill']; } const { width, height: optionsHeight, fit = 'fill', ...rest } = widthOrOptions; if (width === undefined && optionsHeight === undefined) throw new TypeError('resize() needs a width, a height or both'); - if (fit !== 'fill' && fit !== 'inside') throw new TypeError(`fit must be 'fill' or 'inside', not ${String(fit)}`); + if (!['fill', 'inside', 'contain', 'cover'].includes(fit)) throw new TypeError(`fit must be 'fill', 'inside', 'contain' or 'cover', not ${String(fit)}`); return [width === undefined ? undefined : finite(width, 'width'), optionsHeight === undefined ? undefined : finite(optionsHeight, 'height'), rest, fit]; } diff --git a/src/index.ts b/src/index.ts index 2121afa8..13a49ae4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,7 @@ import { ImageType } from './image-type.ts'; export { Image } from './image.ts'; export { Colors } from './colors.ts'; -export type { Color, ColorFunction, ResizeMode, TextStyleOptions, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, ContainOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, TGAEncodeOptions, GIFEncodeOptions, DitherMethod, GainMapEmbedOptions, RenderSVGOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts'; +export type { Color, ColorFunction, ResizeMode, ResizeFit, TextStyleOptions, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, TGAEncodeOptions, GIFEncodeOptions, DitherMethod, GainMapEmbedOptions, RenderSVGOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts'; export { Frame } from './frame.ts'; export type { FrameDisposalMode, FrameOptions } from './frame.ts'; export { GainMap } from './gainmap.ts'; diff --git a/tests/api-shapes.js b/tests/api-shapes.js index b4f01ca1..bda2b3e8 100644 --- a/tests/api-shapes.js +++ b/tests/api-shapes.js @@ -14,19 +14,21 @@ const throws = (call, type, message) => { (async () => { const photo = () => new Image(40, 20).fill((x, y) => Colors.fromRGBA(x * 6, y * 12, 128, 255)); - // contain() pads to the box (transparent, or the background color); resize({ fit: 'inside' }) only resizes + // resize fit modes: contain pads to the box; inside only resizes { - const padded = photo().contain(30, 30); + const padded = photo().resize({width: 30, height: 30, fit: 'contain'}); assert(padded.width === 30 && padded.height === 30, `contain: ${padded}`); assert(padded.getPixelAt(0, 0) === 0 && padded.getPixelAt(15, 15) >>> 0 !== 0, 'contain: transparent bars, the image in the middle'); - const black = photo().contain(30, 30, {background: 0x000000ff}); + const black = photo().resize({width: 30, height: 30, fit: 'contain', background: 0x000000ff}); assert(black.getPixelAt(0, 0) === 0x000000ff, 'contain: background color in the bars'); const inside = photo().resize({width: 30, height: 30, fit: 'inside'}); assert(inside.width === 30 && inside.height === 15, `resize fit inside: ${inside}`); + const covered = photo().resize({width: 30, height: 30, fit: 'cover'}); + assert(covered.width === 30 && covered.height === 30, `resize fit cover: ${covered}`); const filled = photo().resize({width: 30, height: 30}); assert(filled.width === 30 && filled.height === 30, 'resize: fill is the default'); - throws(() => photo().resize({width: 30, height: 30, fit: 'contain'}), TypeError, 'resize: unknown fit'); - throws(() => photo().contain(30, 30, {background: NaN}), RangeError, 'contain: NaN background'); + throws(() => photo().resize({width: 30, height: 30, fit: 'contain', background: NaN}), RangeError, 'contain: NaN background'); + throws(() => photo().resize({width: 30, fit: 'contain'}), TypeError, 'contain requires both dimensions'); const frame = new Frame(photo(), {xOffset: 10, yOffset: 4}); frame.resize({width: 20, height: 20, fit: 'inside'}); assert(frame.width === 20 && frame.height === 10 && frame.xOffset === 5 && frame.yOffset === 2, `Frame.resize fit inside: ${frame} ${frame.xOffset},${frame.yOffset}`); @@ -91,14 +93,14 @@ const throws = (call, type, message) => { assert(cropped.frames[0].width === 10 && cropped.frames[0].xOffset === 0 && cropped.frames[1].xOffset === 5 && cropped.frames[1].width === 5, 'crop: frames cut and moved'); throws(() => make().crop(30, 0, 5, 5), RangeError, 'crop outside the canvas'); - const contained = make().contain(40, 40); + const contained = make().resize({width: 40, height: 40, fit: 'contain'}); assert(contained.width === 40 && contained.height === 40 && contained.frames[0].width === 40 && contained.frames[0].yOffset === 10, `contain: ${contained} ${contained.frames[0]} ${contained.frames[0].yOffset}`); - const withBackground = make().contain(40, 40, {background: 0xffffffff}); + const withBackground = make().resize({width: 40, height: 40, fit: 'contain', background: 0xffffffff}); assert(withBackground.frames.every(f => f.width === 40 && f.height === 40 && f.image.getPixelAt(0, 0) === 0xffffffff), 'contain: background frames'); assert(withBackground.frames[1].image.getPixelAt(30, 20) === 0x0000ffff && withBackground.frames[1].image.getPixelAt(5, 20) === 0xff0000ff, 'contain: background frames show the composited animation'); assert(withBackground.frames.map(f => f.duration).join() === '50,70', 'contain: durations kept'); - const covered = make().cover(10, 10); + const covered = make().resize({width: 10, height: 10, fit: 'cover'}); assert(covered.width === 10 && covered.height === 10, `cover: ${covered}`); const inside = make().resize({width: 10, height: 10, fit: 'inside'}); assert(inside.width === 10 && inside.height === 5, `Animation.resize fit inside: ${inside}`); @@ -255,9 +257,9 @@ const throws = (call, type, message) => { assert(turned.width === 10 && turned.height === 20 && turned.xOffset === 15 && turned.yOffset === 5, `Frame.rotate(90) keeps its middle: ${turned.xOffset},${turned.yOffset}`); const tilted = new Frame(new Image(20, 20), {xOffset: 50, yOffset: 50}).rotate(45); assert(Math.abs(tilted.xOffset + tilted.width / 2 - 60) <= 0.5 && Math.abs(tilted.yOffset + tilted.height / 2 - 60) <= 0.5, 'Frame.rotate(45) keeps its middle'); - const contained = new Frame(new Image(20, 10), {xOffset: 10, yOffset: 6}).contain(40, 40); + const contained = new Frame(new Image(20, 10), {xOffset: 10, yOffset: 6}).resize({width: 40, height: 40, fit: 'contain'}); assert(contained.width === 40 && contained.height === 40 && contained.xOffset === 20 && contained.yOffset === 2, `Frame.contain: ${contained.xOffset},${contained.yOffset}`); - const covered = new Frame(new Image(20, 10), {xOffset: 10, yOffset: 6}).cover(10, 10); + const covered = new Frame(new Image(20, 10), {xOffset: 10, yOffset: 6}).resize({width: 10, height: 10, fit: 'cover'}); assert(covered.width === 10 && covered.xOffset === 15 && covered.yOffset === 6, `Frame.cover: ${covered.xOffset},${covered.yOffset}`); const viaImage = new Frame(square(), {xOffset: 2, yOffset: 5}); viaImage.image.dropShadow({blur: 2}); @@ -297,6 +299,6 @@ const throws = (call, type, message) => { { const gray = new Image(1, 1).fill(0xff0000ff).grayscale(); assert(gray.getPixelAt(0, 0) === 0x4c4c4cff, `grayscale: ${gray.getPixelAt(0, 0).toString(16)}`); - assert(new Image(1, 1).greyscale === undefined && new Image(1, 1).drawBox === undefined && new Image(1, 1).fit === undefined && new Image(1, 1).getRGBAAt === undefined && new Image(1, 1).region === undefined, 'removed methods'); + assert(new Image(1, 1).greyscale === undefined && new Image(1, 1).drawBox === undefined && new Image(1, 1).fit === undefined && new Image(1, 1).contain === undefined && new Image(1, 1).cover === undefined && new Frame(new Image(1, 1)).contain === undefined && new Frame(new Image(1, 1)).cover === undefined && new Animation([]).contain === undefined && new Animation([]).cover === undefined && new Image(1, 1).getRGBAAt === undefined && new Image(1, 1).region === undefined, 'removed methods'); } })(); diff --git a/tests/gainmap.js b/tests/gainmap.js index 6c5bcfbb..3b8e3253 100644 --- a/tests/gainmap.js +++ b/tests/gainmap.js @@ -271,7 +271,7 @@ const meanValue = (image, left, top, right, bottom) => { } // invalid arguments throw before the image or its map changes - for (const [name, call] of [['blur', i => i.blur('5')], ['fill', i => i.fill('red')], ['cover', i => i.cover(-1, 10)]]) { + for (const [name, call] of [['blur', i => i.blur('5')], ['fill', i => i.fill('red')], ['resize cover', i => i.resize({width: -1, height: 10, fit: 'cover'})]]) { const image = single.clone(); let threw = false; try { call(image); } catch { threw = true; } diff --git a/tests/options-only.js b/tests/options-only.js index 73f9cd3d..8ea6f8a7 100644 --- a/tests/options-only.js +++ b/tests/options-only.js @@ -21,10 +21,10 @@ const sameImage = (image, reference, label) => { (async () => { const photo = new Image(20, 12).fill((x, y) => 0x336699ff + x * 0x01000000 + y * 0x00010000 >>> 0); - // resize, scale, contain and cover take the resizing mode in the options only + // resize and scale take the sampling mode in the options only const modes = {'a mode name': 'area', 'a string': 'bilinear', 'an unknown mode': 'Area', 'null': null}; for (const [label, mode] of Object.entries(modes)) { - for (const method of ['resize', 'contain', 'cover']) { + for (const method of ['resize']) { const image = photo.clone(); const error = await rejects(() => image[method](10, 6, mode), `${method}(10, 6, ${label})`); if (!/takes an options object/.test(error.message)) panic(`${method}(10, 6, ${label}): the message should name the options form, got ${error.message}`); @@ -142,8 +142,8 @@ const sameImage = (image, reference, label) => { if (photo.clone().resize(10, 6, undefined).height !== 6) panic('resize(10, 6, undefined)'); if (photo.clone().scale(0.5, {mode: 'area'}).width !== 10) panic('scale(0.5, { mode })'); if (photo.clone().resize({width: 10, height: 10, fit: 'inside', mode: 'area'}).width !== 10) panic('resize({ width, height, fit: \'inside\', mode })'); - if (photo.clone().contain(10, 10, {mode: 'area'}).height !== 10) panic('contain(10, 10, { mode })'); - if (photo.clone().cover(10, 10, {mode: 'area'}).width !== 10) panic('cover(10, 10, { mode })'); + if (photo.clone().resize({width: 10, height: 10, fit: 'contain', mode: 'area'}).height !== 10) panic('resize({ fit: \'contain\', mode })'); + if (photo.clone().resize({width: 10, height: 10, fit: 'cover', mode: 'area'}).width !== 10) panic('resize({ fit: \'cover\', mode })'); if (new Frame(new Image(20, 12)).resize(10, 6, {mode: 'area'}).width !== 10) panic('Frame.resize(10, 6, { mode })'); if (new Animation([new Frame(new Image(20, 12))]).resize(10, 6, {mode: 'area'}).frames[0].width !== 10) panic('Animation.resize(10, 6, { mode })'); })().catch(error => panic(error.stack || String(error))); diff --git a/tests/regressions.js b/tests/regressions.js index afa9aa68..6ec8f7fc 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -165,13 +165,13 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); assert(rgba(image, 0, 0)[0] === 20, 'composite: 100 at 20% over black should be 20, got ' + rgba(image, 0, 0)[0]); } - // cover and contain hit the requested dimensions despite floating point scale factors + // cover and contain fit modes hit the requested dimensions despite floating point scale factors { for (let width = 1; width < 200; width++) { for (const target of [7, 50, 99, 128]) { - const covered = new Image(width, 61).cover(target, 33); + const covered = new Image(width, 61).resize({width: target, height: 33, fit: 'cover'}); assert(covered.width === target && covered.height === 33, `cover(${target}, 33) of ${width}x61 returned ${covered}`); - const contained = new Image(width, 61).contain(target, 33); + const contained = new Image(width, 61).resize({width: target, height: 33, fit: 'contain'}); assert(contained.width === target && contained.height === 33, `contain(${target}, 33) of ${width}x61 returned ${contained}`); const inside = new Image(width, 61).resize({width: target, height: 33, fit: 'inside'}); assert((inside.width === target || inside.height === 33) && inside.width <= target && inside.height <= 33, `resize({ width: ${target}, height: 33, fit: 'inside' }) of ${width}x61 returned ${inside}`); @@ -1003,7 +1003,7 @@ const rgba = (image, x, y) => Colors.toRGBA(image.getPixelAt(x, y)); const image = () => new Image(8, 8).fill(0xff0000ff); const calls = { rgbaToColor: v => Colors.fromRGBA(v, 0, 0, 255), hslaToColor: v => Colors.fromHSLA(v, 0.5, 0.5, 1), colorToRGBA: v => Colors.toRGBA(v), - getPixelAt: v => image().getPixelAt(v, 1), setPixelAt: v => image().setPixelAt(1, 1, v), fill: v => image().fill(v), contain: v => image().contain(v, 4), + getPixelAt: v => image().getPixelAt(v, 1), setPixelAt: v => image().setPixelAt(1, 1, v), fill: v => image().fill(v), contain: v => image().resize({width: v, height: 4, fit: 'contain'}), crop: v => image().crop(v, 0, 2, 2), trim: v => image().trim({color: v}), cropCircle: v => image().cropCircle({ feathering: v }), opacity: v => image().opacity(v), red: v => image().red(v), lightness: v => image().lightness(v), composite: v => image().composite(image(), {x: v, y: 0}), hueShift: v => image().hueShift(v), brightness: v => image().brightness(v), gamma: v => image().gamma(v), From 8af89eaafa55752ed31d1bf661e88c4364c86f67 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Mon, 9 Nov 2026 20:48:06 +0100 Subject: [PATCH 075/188] Freeze animation frame snapshots --- CHANGELOG.md | 2 +- guides/Migrating to 4.0.md | 11 ++++++----- src/animation.ts | 17 ++++++++++------- tests/animation-class.js | 20 +++++++++++++------- tests/animation.js | 7 ++++--- 5 files changed, 34 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c51d5a69..befc6793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,7 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **The `GIF` class is removed**: `new GIF(frames, loopCount)` is `new Animation(frames, { loopCount })`, `GIF.decode(data)` is `Animation.decode(data)` and `gif.encode(quality)` is `animation.encodeGIF({ colors })`. `Animation` decodes and encodes GIFs the same way it does APNGs and WebPs. -- **`Animation` holds its frames instead of extending `Array`**: `animation.frames` is the array the animation uses, and `animation[0]`, `animation.push(frame)`, `animation.map(...)` and the other array methods are `animation.frames[0]`, `animation.frames.push(frame)`, `animation.frames.map(...)`, and so on. `animation.slice(start, end)` is `new Animation(animation.frames.slice(start, end), { loopCount: animation.loopCount })`. `for (const frame of animation)`, `animation.length` and the methods of `Animation` work as before, and `Array.isArray(animation)` is `false`. +- **`Animation` holds its frames instead of extending `Array`**: `animation.frames` is a frozen snapshot; the `Frame` objects remain mutable. To change the list, assign a new array (the setter copies it). `animation[0]`, `animation.push(frame)` and `animation.map(...)` become `animation.frames[0]`, `animation.frames = [...animation.frames, frame]` and `animation.frames.map(...)`. `animation.slice(start, end)` is `new Animation(animation.frames.slice(start, end), { loopCount: animation.loopCount })`. `for (const frame of animation)`, `animation.length` and the methods of `Animation` work as before, and `Array.isArray(animation)` is `false`. - **Loop counts are `Infinity` (forever, the default) or an integer from 0**: `loopCount: -1` is `loopCount: Infinity`, and the encoders and the decoded animations use `Infinity` where they used `-1`. `-1` now throws a `RangeError` like the other negative numbers, `NaN` and fractions do, and numeric strings, `null` and booleans throw a `TypeError`. The check applies to `new Animation(frames, { loopCount })`, `animation.loopCount = ...` (which leaves the count as it was when it throws) and the encoders (`Animation.gifEncoder()`, `webpEncoder()` and `apngEncoder()`). Finite counts are the same as before: `loopCount: 2` still plays three times. diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index 8f797fa2..b6769986 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -66,17 +66,18 @@ const widths = animation.map(f => f.width); // 4.0 const first = animation.frames[0]; -animation.frames.push(frame); +animation.frames = [...animation.frames, frame]; const copy = new Animation(animation.frames.slice(0, 10), { loopCount: animation.loopCount }); const widths = animation.frames.map(f => f.width); ``` These still work: `for (const frame of animation)`, `[...animation]`, `Array.from(animation)`, `animation.length` and the methods of `Animation` itself (`resize()`, `encodeGIF()`, `width`, `duration`, ...). `Array.isArray(animation)` is -`false` now. +`false` now. `animation.frames` is a frozen snapshot, so array mutation methods and index assignment don't change it. +The `Frame` objects themselves remain mutable; replace the list by assigning a new array to `animation.frames`. -Two details: `animation.length` is read-only, so `animation.length = 0` throws. Assigning `animation.frames = list` -copies `list` into a new array, so later changes to `list` don't reach the animation. Change `animation.frames` instead. +`animation.length` is read-only, so `animation.length = 0` throws. Assigning `animation.frames = list` copies `list` +into a new array, so later changes to `list` don't reach the animation. Search your code for: `Array.isArray`, `.slice(`, `.map(`, `.filter(`, `.forEach(`, `.reduce(`, `.push(`, `.splice(`, `.length =` and `[0]` on variables that hold an animation. @@ -286,7 +287,7 @@ Do it per pixel: read the color with `Colors.toHSLA()`, set the lightness or sat | `gif.encode(quality)` | `animation.encodeGIF({ colors })`, see the [GIF options](#renamed-and-reshaped-apis) | | `GIF` (the class, also in `instanceof`) | `Animation`. `GIF` is no longer exported | | `animation.slice(start, end)` | `new Animation(animation.frames.slice(start, end), { loopCount: animation.loopCount })` | -| `animation[i]` and the array methods | `animation.frames[i]` and `animation.frames.` | +| `animation[i]` and the array methods | `animation.frames[i]` and non-mutating array methods; assign a new array to change the frame list | | `AnimationEncoder.frames` | `AnimationEncoder.frameCount` | | `toString()` of a GIF, `GIF<64x64x1000ms>` | `Animation<64x64x1000ms>` | diff --git a/src/animation.ts b/src/animation.ts index 9cc10dc2..e160f922 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -93,6 +93,9 @@ export class Animation { /** @internal @ignore */ private __frames__: Frame[] = []; + /** @internal @ignore */ + private __framesView__: readonly Frame[] | null = null; + /** @internal @ignore the canvas size, once set (else the frames' extent) */ private __canvas__: [width: number, height: number] | null = null; @@ -131,25 +134,25 @@ export class Animation { } /** - * The animation's frames: the array the animation uses, so changing it (`push()`, `splice()`, setting an index) - * changes the animation. Assigning replaces them: the frames are copied into a new array, which is then the one - * the animation uses + * The animation's frames as a frozen array. The frames themselves are mutable; assign a new array to replace the + * list */ - get frames(): Frame[] { - return this.__frames__; + get frames(): readonly Frame[] { + return this.__framesView__ ??= Object.freeze([...this.__frames__]); } /** * Replaces the animation's frames - * @param frames The new frames (copied into the animation, the array itself isn't kept) + * @param frames The new frames (copied into the animation; the array itself isn't kept) */ - set frames(frames: Frame[]) { + set frames(frames: readonly Frame[]) { const copy: Frame[] = []; for (const frame of frames) { if (!(frame instanceof Frame)) throw new TypeError(`Frame ${copy.length} is not an instance of Frame`); copy.push(frame); } this.__frames__ = copy; + this.__framesView__ = null; } /** The number of frames */ diff --git a/tests/animation-class.js b/tests/animation-class.js index 91f05433..aff5d7b7 100644 --- a/tests/animation-class.js +++ b/tests/animation-class.js @@ -29,16 +29,22 @@ if (animation[0] !== undefined) panic('animation[0] should be gone: use animatio if (Animation[Symbol.species] !== undefined) panic('Animation[Symbol.species] should be gone'); if (lib.GIF !== undefined) panic('the GIF export should be gone'); -// frames: the array the animation uses, live and assignable +// frames: a frozen snapshot; the Frame objects remain mutable and replacing the list is assignable if (animation.frames[0] !== a || animation.frames[1] !== b) panic('frames should be the frames'); -animation.frames.push(c); -if (animation.length !== 3 || animation.duration !== 60) panic('pushing to frames should add a frame'); -animation.frames.splice(1, 1); -if (animation.length !== 2 || animation.frames[1] !== c) panic('splicing frames should remove a frame'); -animation.frames[0] = b; -if (animation.frames[0] !== b) panic('setting an index of frames should replace the frame'); +const originalFrames = animation.frames; +if (!Object.isFrozen(originalFrames)) panic('frames should be frozen'); +if (Reflect.set(originalFrames, 0, c) || animation.frames[0] !== a) panic('setting a frame index should not change the animation'); +throwsA(() => originalFrames.push(c), TypeError, 'pushing to frozen frames'); +animation.frames[0].duration = 15; +if (animation.frames[0].duration !== 15) panic('frames themselves should remain mutable'); +animation.frames[0].duration = 10; +animation.frames = [...animation.frames, c]; +if (animation.length !== 3 || animation.duration !== 60) panic('assigning an added frame should add a frame'); +animation.frames = [animation.frames[0], animation.frames[2]]; +if (animation.length !== 2 || animation.frames[1] !== c) panic('assigning a new frame list should remove a frame'); animation.frames = [a, c]; if (animation.length !== 2 || animation.frames[0] !== a || animation.frames[1] !== c) panic('assigning frames should replace them'); +if (originalFrames === animation.frames || originalFrames.length !== 2) panic('replacing frames should return a new snapshot'); // assigning copies: the array given isn't the one the animation uses, and a bad frame changes nothing const list = [b, c]; diff --git a/tests/animation.js b/tests/animation.js index cc0fdd7a..56be30b2 100644 --- a/tests/animation.js +++ b/tests/animation.js @@ -57,12 +57,13 @@ const square = (color, w, h) => new Image(w, h).fill((x, y) => (x + y) % 5 === 0 if (!(gif instanceof Animation) || gif.length !== 1) panic('Animation.decode should decode GIFs'); if (!(new Animation(gif.frames.slice(0, 1), {loopCount: gif.loopCount}) instanceof Animation)) panic('a new animation of some frames'); - // frames: the animation's frames, changing them changes the animation + // frames: the returned array is frozen; assign a new list to change the animation { const animation = new Animation([new Frame(still.clone())]); if (animation.frames.length !== 1) panic('frames should hold the frames'); - animation.frames.push(new Frame(still.clone(), {duration: 50})); - if (animation.length !== 2 || animation.duration !== 150) panic('frames.push should add a frame'); + if (!Object.isFrozen(animation.frames)) panic('frames should be frozen'); + animation.frames = [...animation.frames, new Frame(still.clone(), {duration: 50})]; + if (animation.length !== 2 || animation.duration !== 150) panic('assigning an added frame should add a frame'); if (!Array.isArray(animation.frames.map(f => f.duration))) panic('frames.map should return an array'); animation.frames = animation.frames.filter(f => f.duration === 50); if (animation.length !== 1 || animation.frames[0].duration !== 50) panic('setting frames should replace them'); From 7c2a53c6f5dae56738b7b2506b000a92ca2f8f72 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Mon, 9 Nov 2026 20:52:27 +0100 Subject: [PATCH 076/188] Tighten fixed-length API tuple types --- CHANGELOG.md | 2 ++ guides/Migrating to 4.0.md | 6 ++++++ src/animation.ts | 4 ++-- src/frame.ts | 4 ++-- src/gainmap.ts | 27 +++++++++++++++------------ src/image.ts | 5 ++++- src/index.ts | 4 ++-- 7 files changed, 33 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index befc6793..d88ad4e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,8 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **Removed and narrowed exported types**: `TextOptions` is `RenderTextOptions`, `FrameDisposalModeId` is removed (`FrameDisposalMode` has the names), `AdjustOptions` is removed with `absolute`, `PNGMetadata` is `Metadata`, and `AnimationEncoderOptions`, `WebPAnimationEncoderOptions`, `GIFAnimationEncoderOptions` and `APNGAnimationEncoderOptions` are `AnimationEncodeOptions`, `WebPAnimationEncodeOptions`, `GIFAnimationEncodeOptions` and `APNGEncodeOptions`. `ResizeMode` is narrowed to the lower-case names (`'area'`, ...), `ContainOptions` is removed, and `SVGScaleMode` is removed. +- **Fixed-length tuple types**: `Image`, `Frame` and `Animation.transform()` take `AffineTransform` (exactly six values); gain-map channel options and setters take a scalar or a three-value tuple (`GainMapChannelValues`), not an arbitrary `number[]`. + - **Hues are in degrees**: `Colors.fromHSL()`, `Colors.fromHSLA()` and `Colors.toHSLA()` take and return the hue in degrees (0-360, like CSS' `hsl()` and `hueShift()`) instead of 0-1. - **`drawBox()` and `drawCircle()` are removed**: `image.composite(new Image(w, h).fill(color), { x, y })` draws a box, with `.cropCircle()` a circle. Compositing blends a translucent color over the image, where `drawBox()` replaced the pixels. diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index b6769986..4c4ef61a 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -397,6 +397,12 @@ false })` grows the image again), so search for them too. | `new GainMap(image, { gainMapMin, gainMapMax })`, `map.gainMapMin`, `map.gainMapMax` | `new GainMap(image, { min, max })`, `map.min`, `map.max` | | `setPixelAt(x, y, pixelColor)`, `lightness(value)`, `saturation(value)` (parameter names only) | `setPixelAt(x, y, color)`, `lightness(factor)`, `saturation(factor)` | +### Fixed-length tuple types + +`transform()` on images, frames and animations takes an `AffineTransform` with exactly six values. Gain-map channel +parameters take a scalar or a three-value `[red, green, blue]` tuple (`GainMapChannelValues`); use a scalar for the +shared-channel form instead of a one-element array. + ### Frames hold an image A `Frame` is no longer an `Image`: it holds one, `frame.image`, with the frame's duration, offsets and disposal mode. diff --git a/src/animation.ts b/src/animation.ts index e160f922..aa935938 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -6,7 +6,7 @@ import * as jxl from './codecs/jxl.ts'; import { availableThreads } from './util/threads.ts'; import { BinaryData, view } from './util/mem.ts'; import { GainMap } from './gainmap.ts'; -import { Image, DecodeOptions, EncodeOptions, GIFEncodeOptions, ResizeOptions, ResizeToOptions, RotateOptions, TransformOptions, ShadowOptions, OutlineOptions, TrimOptions, resizeArguments, insideSize } from './image.ts'; +import { Image, DecodeOptions, EncodeOptions, GIFEncodeOptions, ResizeOptions, ResizeToOptions, RotateOptions, TransformOptions, AffineTransform, ShadowOptions, OutlineOptions, TrimOptions, resizeArguments, insideSize } from './image.ts'; import { Frame } from './frame.ts'; import { ImageType } from './image-type.ts'; import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts'; @@ -359,7 +359,7 @@ export class Animation { * @param matrix The six values [a, b, c, d, e, f] * @param options See {@link TransformOptions} */ - transform(matrix: ArrayLike, options?: TransformOptions): this { + transform(matrix: AffineTransform, options?: TransformOptions): this { return this.__each__(image => image.transform(matrix, options)); } diff --git a/src/frame.ts b/src/frame.ts index b834b7ce..aa06d100 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -1,4 +1,4 @@ -import { Image, ResizeOptions, ResizeToOptions, TrimOptions, RotateOptions, TransformOptions, ShadowOptions, OutlineOptions } from './image.ts'; +import { Image, ResizeOptions, ResizeToOptions, TrimOptions, RotateOptions, TransformOptions, AffineTransform, ShadowOptions, OutlineOptions } from './image.ts'; import { checkOptions, finite } from './util/options.ts'; /** @@ -263,7 +263,7 @@ export class Frame { * @param matrix The six values [a, b, c, d, e, f] * @param options See {@link TransformOptions} */ - transform(matrix: ArrayLike, options?: TransformOptions): this { + transform(matrix: AffineTransform, options?: TransformOptions): this { return this.__tracked__(image => image.transform(matrix, options)); } diff --git a/src/gainmap.ts b/src/gainmap.ts index 6987d0b8..4c24c205 100644 --- a/src/gainmap.ts +++ b/src/gainmap.ts @@ -10,18 +10,21 @@ export interface GainMapEncodeOptions { quality?: number; } -/** Options of the {@link GainMap} constructor: per channel values can be one number for all three or [r, g, b] */ +/** A gain-map parameter shared across channels or specified as `[red, green, blue]` */ +export type GainMapChannelValues = number | readonly [red: number, green: number, blue: number]; + +/** Options of the {@link GainMap} constructor */ export interface GainMapOptions { /** log2 of the boost of the map's value 0 (`-1`: half as bright; hdrgm's GainMapMin). Default: `0` */ - min?: number | number[]; + min?: GainMapChannelValues; /** log2 of the boost of the map's value 255 (`2`: 4 times as bright; hdrgm's GainMapMax). Default: `2` */ - max?: number | number[]; + max?: GainMapChannelValues; /** the gamma the map's values are stored with. Default: `1` */ - gamma?: number | number[]; + gamma?: GainMapChannelValues; /** added to the SDR image's linear values before the boost (keeps black from being boosted forever). Default: `1/64` */ - offsetSDR?: number | number[]; + offsetSDR?: GainMapChannelValues; /** subtracted from the HDR result. Default: `1/64` */ - offsetHDR?: number | number[]; + offsetHDR?: GainMapChannelValues; /** log2 of the display headroom from which on the map starts to apply. Default: `0` */ hdrCapacityMin?: number; /** log2 of the display headroom at which it applies fully. Default: `max`'s largest value */ @@ -30,7 +33,7 @@ export interface GainMapOptions { useBaseColorSpace?: boolean; } -const perChannel = (value: number | number[], name: string): number[] => { +const perChannel = (value: GainMapChannelValues, name: string): number[] => { const values = typeof value === 'number' ? [value, value, value] : Array.from(value); if (values.length === 1) values.push(values[0], values[0]); if (values.length !== 3) throw new RangeError(`${name} must be a number or an array of 3 numbers`); @@ -73,21 +76,21 @@ export class GainMap { * Replaces the value (checked: an invalid one throws and the old one stays) * @param value One number for all channels or [r, g, b] */ - set min(value: number | number[]) { this.__min__ = perChannel(value, 'min'); } + set min(value: GainMapChannelValues) { this.__min__ = perChannel(value, 'min'); } /** log2 of the boost of the map's value 255, per channel (a copy: assign to change it) */ get max(): number[] { return this.__max__.slice(); } /** * Replaces the value (checked: an invalid one throws and the old one stays) * @param value One number for all channels or [r, g, b] */ - set max(value: number | number[]) { this.__max__ = perChannel(value, 'max'); } + set max(value: GainMapChannelValues) { this.__max__ = perChannel(value, 'max'); } /** The gamma the map's values are stored with, per channel (a copy: assign to change it) */ get gamma(): number[] { return this.__gamma__.slice(); } /** * Replaces the value (checked: an invalid one throws and the old one stays) * @param value One number above 0 for all channels or [r, g, b] */ - set gamma(value: number | number[]) { + set gamma(value: GainMapChannelValues) { const gamma = perChannel(value, 'gamma'); if (gamma.some(g => g <= 0)) throw new RangeError('The gain map\'s gamma must be above 0'); this.__gamma__ = gamma; @@ -98,14 +101,14 @@ export class GainMap { * Replaces the value (checked: an invalid one throws and the old one stays) * @param value One number for all channels or [r, g, b] */ - set offsetSDR(value: number | number[]) { this.__offsetSDR__ = perChannel(value, 'offsetSDR'); } + set offsetSDR(value: GainMapChannelValues) { this.__offsetSDR__ = perChannel(value, 'offsetSDR'); } /** Subtracted from the HDR result, per channel (a copy: assign to change it) */ get offsetHDR(): number[] { return this.__offsetHDR__.slice(); } /** * Replaces the value (checked: an invalid one throws and the old one stays) * @param value One number for all channels or [r, g, b] */ - set offsetHDR(value: number | number[]) { this.__offsetHDR__ = perChannel(value, 'offsetHDR'); } + set offsetHDR(value: GainMapChannelValues) { this.__offsetHDR__ = perChannel(value, 'offsetHDR'); } /** log2 of the display headroom from which on the map starts to apply (from 0) */ get hdrCapacityMin(): number { return this.__capacityMin__; } /** diff --git a/src/image.ts b/src/image.ts index 779ad998..92adbfc4 100644 --- a/src/image.ts +++ b/src/image.ts @@ -176,6 +176,9 @@ export interface QuantizeOptions { /** How {@link Image.transform} and {@link Image.rotate} sample the pixels between the source's */ export type TransformMode = 'nearest-neighbor' | 'bilinear' | 'bicubic'; +/** The six values `[a, b, c, d, e, f]` of an affine transform */ +export type AffineTransform = readonly [a: number, b: number, c: number, d: number, e: number, f: number]; + // the sampling modes of Image.transform() const TRANSFORM_MODES: readonly string[] = ['nearest-neighbor', 'bilinear', 'bicubic']; @@ -1799,7 +1802,7 @@ export class Image { * @param matrix The six values [a, b, c, d, e, f] * @param options See {@link TransformOptions} */ - transform(matrix: ArrayLike, options: TransformOptions = {}): this { + transform(matrix: AffineTransform, options: TransformOptions = {}): this { checkOptions(options, 'image.transform([1, 0, 0, 1, 0, 0], { expand: false })'); if (matrix === null || typeof matrix !== 'object' || matrix.length !== 6) throw new RangeError('A transform has 6 values: [a, b, c, d, e, f]'); // (numbers only: a string is a mistake in data like this) diff --git a/src/index.ts b/src/index.ts index 13a49ae4..1bf45c1e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,11 +9,11 @@ import { ImageType } from './image-type.ts'; export { Image } from './image.ts'; export { Colors } from './colors.ts'; -export type { Color, ColorFunction, ResizeMode, ResizeFit, TextStyleOptions, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, TGAEncodeOptions, GIFEncodeOptions, DitherMethod, GainMapEmbedOptions, RenderSVGOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts'; +export type { Color, ColorFunction, ResizeMode, ResizeFit, TextStyleOptions, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, TGAEncodeOptions, GIFEncodeOptions, DitherMethod, GainMapEmbedOptions, RenderSVGOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, AffineTransform, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts'; export { Frame } from './frame.ts'; export type { FrameDisposalMode, FrameOptions } from './frame.ts'; export { GainMap } from './gainmap.ts'; -export type { GainMapOptions, GainMapEncodeOptions } from './gainmap.ts'; +export type { GainMapOptions, GainMapEncodeOptions, GainMapChannelValues } from './gainmap.ts'; export { Animation } from './animation.ts'; export type { AnimationOptions, AnimationCanvasOptions, AnimationEncodeOptions, WebPAnimationEncodeOptions, GIFAnimationEncodeOptions, APNGEncodeOptions } from './animation.ts'; export { AnimationEncoder } from './animation-encoder.ts'; From 7b734442cf456e184586481cb4c32ce016d8767d Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Mon, 9 Nov 2026 21:02:21 +0100 Subject: [PATCH 077/188] Preserve explicit animation decode signals --- CHANGELOG.md | 4 ++-- guides/Migrating to 4.0.md | 14 ++++++++------ src/animation.ts | 28 +++++++++++++++++++++++----- src/codecs/gif.ts | 5 ++++- src/codecs/jxl.ts | 4 ++-- src/index.ts | 12 ++++++------ tests/decode-frames.js | 31 +++++++++++++++++++------------ 7 files changed, 64 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d88ad4e9..9cef7e88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,7 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **`Image.decode()` returns the first frame of an animation**: a GIF or an animated WebP, which threw in 3.x, is an `Image` of its first frame, composed onto the canvas as `Animation.decode()` composes it, and decoding stops after that frame, so it is fast on long animations. Animated JPEG XLs and AVIF sequences were already their first frame. An APNG is still its default image, what viewers without APNG support show. Use `Animation.decode()` for all the frames. -- **`decode()` returns an `Animation` only for files with several frames**: a GIF, APNG, animated WebP or JPEG XL with more than one frame is an `Animation` (`decode(gif)` used to be a `GIF`), and one with a single frame is an `Image`, like a still image (a one-frame GIF used to be a `GIF`). For the first frame alone, `Image.decode()` returns an `Image`. +- **`decode()` follows animation markers as well as frame count**: files with multiple frames or an explicit animation marker return an `Animation`, including one-frame APNGs and animated WebPs; a static image or an unmarked one-frame GIF returns an `Image`. A one-frame animation keeps its timing and loop metadata. `Image.decode()` still returns only an `Image` of the first frame. - **The `GIF` class is removed**: `new GIF(frames, loopCount)` is `new Animation(frames, { loopCount })`, `GIF.decode(data)` is `Animation.decode(data)` and `gif.encode(quality)` is `animation.encodeGIF({ colors })`. `Animation` decodes and encodes GIFs the same way it does APNGs and WebPs. @@ -92,7 +92,7 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation ### New -- `Animation.clone()` and `Animation.crop()`, plus `'contain'` and `'cover'` fit modes of `Animation.resize()`. +- `Animation.clone()` and `Animation.crop()`. - `resize({ width, height, fit })` on images, frames and animations: `'inside'` fits without padding, `'contain'` pads, and `'cover'` crops while keeping the aspect ratio; `background` sets the bars for `'contain'`. - `encodeJPEG({ subsampling: '4:4:4' | '4:2:0' })`; the default stays 4:2:0 up to quality 90 and 4:4:4 above. - `Colors.gradient()` takes an array of evenly spaced colors, like the `stops` of the gradient functions. diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index 4c4ef61a..cd8be8aa 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -20,13 +20,15 @@ The recommended path: These change what code does without a compile error. -### `decode()` returns an `Animation` for files with several frames +### `decode()` uses animation markers as well as frame count In 3.x, `decode()` returned a `GIF` for every GIF, including a GIF with one frame, an `Animation` for an animated WebP, and an `Image` for everything else. An APNG or a JPEG XL with several frames decoded to its first frame, as an `Image`. -In 4.0, a file with several frames (GIF, APNG, animated WebP or JPEG XL) decodes to an `Animation`. A file with one -frame decodes to an `Image`, like a still image, so a one-frame GIF is an `Image` now. +In 4.0, a file with several frames or an explicit animation marker decodes to an `Animation`. This includes one-frame +APNGs, animated WebPs, JPEG XL animations and GIFs with a loop extension; their timing and loop metadata are retained. +A file with one frame and no animation marker decodes to an `Image`, including a still image or a one-frame GIF without +a loop extension. ```ts // 3.x: a GIF was a GIF, which is an Animation with encode() @@ -42,9 +44,9 @@ if (decoded instanceof Animation) { } ``` -`Image.decode()` returns the first frame of an animated GIF or WebP, which threw in 3.x. The frame is an `Image` composed -onto the canvas like the first frame of `Animation.decode()`, and decoding stops after it, so it is fast for a long -animation. Use `Animation.decode()` for all the frames. An APNG still gives its default image, as before. +`Image.decode()` still returns only an `Image`: for an animated GIF or WebP it returns the first frame composed onto the +canvas like `Animation.decode()`, and decoding stops after it. Use `Animation.decode()` for all frames and their +metadata. An APNG still gives its default image, as before. ```ts const first = await Image.decode(data); // the first frame of an animated GIF or WebP diff --git a/src/animation.ts b/src/animation.ts index aa935938..bd8e2e42 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -90,6 +90,9 @@ export class Animation { /** @internal @ignore */ private __loopCount__ = Infinity; + /** @internal @ignore */ + private __animationSignal__ = false; + /** @internal @ignore */ private __frames__: Frame[] = []; @@ -625,6 +628,11 @@ export class Animation { return decoding(() => Animation.__decode__(input, options)); } + /** @internal @ignore */ + static __is_animated__(animation: Animation): boolean { + return animation.__animationSignal__; + } + /** * @internal * @ignore @@ -646,9 +654,12 @@ export class Animation { if (type === 'gif') return Animation.__gifFrames__(data, maxPixels); if (type === 'webp') { + const animated = webp.isAnimated(data); const decoded = webp.decodeAnimation(data, false, maxPixels); const frames = orient(profile(canvasFrames(decoded.width, decoded.height, webp.composite(decoded)), decoded.icc), decoded.orientation); - return new Animation(frames, { loopCount: fromPlays(decoded.loopCount), width: frames[0]?.width ?? decoded.width, height: frames[0]?.height ?? decoded.height }); + const animation = new Animation(frames, { loopCount: fromPlays(decoded.loopCount), width: frames[0]?.width ?? decoded.width, height: frames[0]?.height ?? decoded.height }); + animation.__animationSignal__ = animated; + return animation; } // (still PNGs decode like images) @@ -659,12 +670,14 @@ export class Animation { if (type === 'png') { const decoded = await png.decodeAnimation(data, false, maxPixels); const frames = orient(profile(canvasFrames(decoded.width, decoded.height, png.compositeAnimation(decoded)), decoded.icc), decoded.orientation); - return new Animation(frames, { loopCount: decoded.frames.length > 1 ? fromPlays(decoded.plays) : Infinity, width: frames[0]?.width ?? decoded.width, height: frames[0]?.height ?? decoded.height }); + const animation = new Animation(frames, { loopCount: fromPlays(decoded.plays), width: frames[0]?.width ?? decoded.width, height: frames[0]?.height ?? decoded.height }); + animation.__animationSignal__ = true; + return animation; } if (type === 'jxl') { const frames: Frame[] = []; - let loops = Infinity, icc: Uint8Array | undefined, space: jxl.ColorSpace | undefined; + let loops = Infinity, animated = false, icc: Uint8Array | undefined, space: jxl.ColorSpace | undefined; let orientation = 1; for await (const decoded of jxl.frames(data, maxPixels, toneMapping, threads)) { const image = new Image(decoded.width, decoded.height); @@ -672,6 +685,7 @@ export class Animation { frames.push(new Frame(image, { duration: decoded.duration, disposalMode: 'background' })); orientation = decoded.orientation; loops = fromPlays(decoded.loopCount); + animated ||= decoded.animated; icc = decoded.icc; space = decoded.colorSpace; } @@ -691,7 +705,9 @@ export class Animation { frame.image.gainMap = null; } } - return new Animation(frames, { loopCount: frames.length > 1 ? loops : Infinity, ...(frames.length ? { width: frames[0].width, height: frames[0].height } : {}) }); + const animation = new Animation(frames, { loopCount: animated ? loops : Infinity, ...(frames.length ? { width: frames[0].width, height: frames[0].height } : {}) }); + animation.__animationSignal__ = animated; + return animation; } if (type) { @@ -708,7 +724,9 @@ export class Animation { private static __gifFrames__(data: Uint8Array, maxPixels: number): Animation { const decoded = gifCodec.decode(data, false, maxPixels); const frames = gifCodec.composite(decoded).map(({ pixels, delay }) => ({ pixels, duration: 10 * delay })); - return new Animation(canvasFrames(decoded.width, decoded.height, frames), { loopCount: decoded.loopCount, width: decoded.width, height: decoded.height }); + const animation = new Animation(canvasFrames(decoded.width, decoded.height, frames), { loopCount: decoded.loopCount, width: decoded.width, height: decoded.height }); + animation.__animationSignal__ = decoded.hasLoopExtension; + return animation; } } diff --git a/src/codecs/gif.ts b/src/codecs/gif.ts index 97a0ec66..6fab0743 100644 --- a/src/codecs/gif.ts +++ b/src/codecs/gif.ts @@ -52,6 +52,7 @@ export interface DecodedGIF { width: number; height: number; loopCount: number; + hasLoopExtension: boolean; frames: DecodedGIFFrame[]; } @@ -240,6 +241,7 @@ export function decode(data: Uint8Array, firstFrameOnly = false, maxPixels = MAX const frames: DecodedGIFFrame[] = []; let loopCount = 0; + let hasLoopExtension = false; let control: GraphicControl | null = null; // the frames' color indices, and later a canvas per frame const budget = new PixelBudget(maxPixels); @@ -252,7 +254,7 @@ export function decode(data: Uint8Array, firstFrameOnly = false, maxPixels = MAX } checkPixels(width, height, maxPixels, Math.max(1, frames.length)); - return { width, height, loopCount, frames }; + return { width, height, loopCount, hasLoopExtension, frames }; function parse(): void { while (reader.offset < data.length) { @@ -278,6 +280,7 @@ export function decode(data: Uint8Array, firstFrameOnly = false, maxPixels = MAX const id = reader.string(size); if (id === 'NETSCAPE2.0' || id === 'ANIMEXTS1.0') { + hasLoopExtension = true; // sub-block 1 holds the loop count for (let length; (length = reader.u8());) { const sub = reader.slice(length); diff --git a/src/codecs/jxl.ts b/src/codecs/jxl.ts index bee679e7..c2156b96 100644 --- a/src/codecs/jxl.ts +++ b/src/codecs/jxl.ts @@ -134,7 +134,7 @@ export function colorSpace(image: ImageHeader): ColorSpace | undefined { } /** The frames shown (composited, as RGBA), their durations in milliseconds */ -export async function* frames(data: Uint8Array, maxPixels = MAX_PIXELS, toneMapping: ToneMapping = 'reinhard', threads = 1): AsyncGenerator<{ width: number, height: number, pixels: Uint8Array, duration: number, orientation: number, loopCount: number, icc?: Uint8Array, colorSpace?: ColorSpace }> { +export async function* frames(data: Uint8Array, maxPixels = MAX_PIXELS, toneMapping: ToneMapping = 'reinhard', threads = 1): AsyncGenerator<{ width: number, height: number, pixels: Uint8Array, duration: number, orientation: number, loopCount: number, animated: boolean, icc?: Uint8Array, colorSpace?: ColorSpace }> { const d = codestream(data); const r = new BitReader(d); const image = imageHeader(r); @@ -177,7 +177,7 @@ export async function* frames(data: Uint8Array, maxPixels = MAX_PIXELS, toneMapp shown++; yield { width: image.width, height: image.height, pixels: state.output.toRGBA(frame.canvas), duration: frame.header.duration * tick, - orientation: image.orientation, loopCount: a ? a.numLoops : 1, icc, colorSpace: space + orientation: image.orientation, loopCount: a ? a.numLoops : 1, animated: !!a, icc, colorSpace: space }; } if (frame.header.isLast) return; diff --git a/src/index.ts b/src/index.ts index 1bf45c1e..8e223748 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,11 +25,11 @@ export type { BinaryData } from './util/mem.ts'; export type { Metadata } from './metadata.ts'; /** - * Decodes any supported image. A file with more than one frame (GIF, APNG, animated WebP, animated JPEG XL) is - * returned as an {@link Animation} of its frames, and any other file as an {@link Image}: a file with a single frame - * (also a GIF, APNG, WebP or JPEG XL with one frame) becomes an Image of that frame, without its offsets and duration. - * Static formats (PNG, JPEG, AVIF, ...) are always Images, AVIF image sequences decode to their first frame, as - * {@link Image.decode} does. The options are passed on to {@link Animation.decode} or {@link Image.decode}. + * Decodes any supported image. A file with more than one frame or an explicit animation marker (including a one-frame + * APNG or animated WebP) is returned as an {@link Animation}; otherwise it is an {@link Image}. A one-frame animation + * keeps its timing, offsets and loop metadata. Static formats (PNG, JPEG, AVIF, ...) are Images, and AVIF image + * sequences decode to their first frame, as {@link Image.decode} does. The options are passed on to + * {@link Animation.decode} or {@link Image.decode}. * @param input The image data * @param options See {@link DecodeOptions} */ @@ -42,7 +42,7 @@ export async function decode(input: BinaryData, options: DecodeOptions = {}): Pr if (!animated) return Image.decode(input, options); const animation = await Animation.decode(input, options); - if (animation.length !== 1) return animation; + if (animation.length !== 1 || Animation.__is_animated__(animation)) return animation; // the only frame, as an image: its pixels and gain map return animation.frames[0].image; } diff --git a/tests/decode-frames.js b/tests/decode-frames.js index c7184f23..bd69907e 100644 --- a/tests/decode-frames.js +++ b/tests/decode-frames.js @@ -37,20 +37,25 @@ const throwsA = async (fn, type, label) => { const jxlFrames = await decode(jxlAnimation); if (!(jxlFrames instanceof Animation) || jxlFrames.length !== jxlRef.length) panic(`JPEG XL: ${jxlFrames.constructor.name} with ${jxlFrames.length} frames instead of ${jxlRef.length}`); - // one frame: an Image with the frame's pixels, as Animation.decode's first frame has them + // a file with one frame is an Animation only when the format signals animation const single = new Animation([new Frame(new Image(12, 8).fill((x, y) => Colors.fromRGBA(x * 20, y * 30, 90, 255)), {duration: 90, xOffset: 0, yOffset: 0})]); for (const [format, bytes] of [['GIF', await single.encodeGIF()], ['APNG', await single.encodeAPNG()], ['WebP', await single.encodeWebP({quality: 90})]]) { - const image = await decode(bytes); - if (image instanceof Animation || !(image instanceof Image)) panic(`single frame ${format}: ${image.constructor.name} instead of an Image`); + const decoded = await decode(bytes); + if (!(decoded instanceof Animation) || decoded.length !== 1) panic(`single frame ${format}: got ${decoded.constructor.name}`); + if (format === 'GIF' && decoded.loopCount !== Infinity) panic(`single-frame GIF loop count: ${decoded.loopCount}`); const reference = (await Animation.decode(bytes)).frames[0].image; + const image = decoded instanceof Animation ? decoded.frames[0].image : decoded; if (image.width !== reference.width || image.height !== reference.height) panic(`single frame ${format}: ${image.width}x${image.height} instead of ${reference.width}x${reference.height}`); if (sha256(image.bitmap) !== sha256(reference.bitmap)) panic(`single frame ${format}: pixels differ from Animation.decode's first frame`); } - // a single frame with an offset: the image is the canvas, the offset is dropped + // a single-frame animated GIF keeps its canvas and frame metadata const offset = await new Animation([new Frame(new Image(4, 3).fill(0xff0000ff), {xOffset: 2, yOffset: 1, duration: 40})]).encodeGIF({optimize: false}); - const offsetImage = await decode(offset); - if (offsetImage instanceof Animation || offsetImage.width !== 6 || offsetImage.height !== 4) panic(`single frame with an offset: ${offsetImage.width}x${offsetImage.height}`); + const offsetAnimation = await decode(offset); + if (!(offsetAnimation instanceof Animation) || offsetAnimation.width !== 6 || offsetAnimation.height !== 4 || offsetAnimation.frames[0].duration !== 40) + panic(`single frame with an offset: ${offsetAnimation.constructor.name}`); + const stillGif = await decode(await new Animation([single.frames[0].clone()], {loopCount: 0}).encodeGIF()); + if (stillGif instanceof Animation || !(stillGif instanceof Image)) panic('one-frame GIF without a loop extension should be an Image'); // a still JPEG XL is an Image with the pixels Image.decode gives const still = await fs.readFile('./tests/jxl/lossy.jxl'); @@ -74,7 +79,7 @@ const throwsA = async (fn, type, label) => { await throwsA(() => decode(jpeg, {maxPixels: 100}), RangeError, 'maxPixels on a JPEG'); if ((await decode(gif, {threads: 2})).length !== 30) panic('threads: frames changed'); - // Animation.decode returns an Animation for every format, single frames included: only decode() unwraps them + // Animation.decode wraps still images too; top-level decode uses explicit animation markers const alwaysAnimation = async (name, bytes) => { const animation = await Animation.decode(bytes); if (!(animation instanceof Animation) || animation.length !== 1) panic(`${name}: Animation.decode gives ${animation.constructor.name} with ${animation.length} frames, not an Animation of 1`); @@ -95,8 +100,7 @@ const throwsA = async (fn, type, label) => { const oneFrameFiles = {GIF: await single.encodeGIF(), APNG: await single.encodeAPNG(), 'WebP animation': await single.encodeWebP({quality: 90})}; for (const [name, bytes] of Object.entries(oneFrameFiles)) await alwaysAnimation(name, bytes); - // a one-frame APNG that keeps its acTL chunk (the encoder writes none for one frame): the two-frame file cut before - // its second frame, num_frames patched to 1, every chunk's CRC recomputed + // a one-frame APNG cut from a two-frame file, with its play count retained const crcTable = Array.from({length: 256}, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; @@ -130,7 +134,9 @@ const throwsA = async (fn, type, label) => { for (const c of chunks.slice(0, second)) { if (c.type !== 'acTL') { parts.push(pngChunk(c.type, c.data)); continue; } const actl = new Uint8Array(c.data); - new DataView(actl.buffer).setUint32(0, 1); + const actlView = new DataView(actl.buffer); + actlView.setUint32(0, 1); + actlView.setUint32(4, 3); parts.push(pngChunk('acTL', actl)); } parts.push(pngChunk('IEND', new Uint8Array(0))); @@ -140,10 +146,11 @@ const throwsA = async (fn, type, label) => { const oneAnimation = await Animation.decode(oneApng); if (!(oneAnimation instanceof Animation) || oneAnimation.length !== 1) panic(`one-frame APNG with acTL: Animation.decode gives ${oneAnimation.constructor.name} with ${oneAnimation.length} frames`); + if (oneAnimation.loopCount !== 2) panic(`one-frame APNG: loop count ${oneAnimation.loopCount} instead of 2`); if (sha256(oneAnimation.frames[0].image.bitmap) !== sha256((await Animation.decode(apng)).frames[0].image.bitmap)) panic('one-frame APNG with acTL: the frame differs from the first frame of the two-frame file'); const oneImage = await decode(oneApng); - if (oneImage instanceof Animation || !(oneImage instanceof Image)) panic('one-frame APNG with acTL: decode() should give an Image'); - if (sha256(oneImage.bitmap) !== sha256(oneAnimation.frames[0].image.bitmap)) panic('one-frame APNG with acTL: decode() pixels differ from Animation.decode'); + if (!(oneImage instanceof Animation) || oneImage.length !== 1 || oneImage.loopCount !== 2) panic('one-frame APNG with acTL: decode() should preserve the Animation'); + if (sha256(oneImage.frames[0].image.bitmap) !== sha256(oneAnimation.frames[0].image.bitmap)) panic('one-frame APNG with acTL: decode() pixels differ from Animation.decode'); // Image.decode of an animation is its first frame, an Image composed like Animation.decode's first frame: frames that // differ, with offsets and disposal modes, from our encoders (GIF, APNG, animated WebP, lossless and lossy) From 64ab0be1fc678758add7c0c2405abf743f64d0d5 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Mon, 9 Nov 2026 21:15:52 +0100 Subject: [PATCH 078/188] Audit v4 migration documentation --- CHANGELOG.md | 10 +++++----- guides/Migrating to 4.0.md | 12 ++++++++---- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cef7e88..e966d369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Breaking changes -Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation hints, then switch: the [migration guide](https://imagescript.dev/documents/Migrating_to_4.0.html) lists every change and the nine behavior changes that don't show up as deprecations or compile errors. +Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation hints, then switch: the [migration guide](https://imagescript.dev/documents/Migrating_to_4.0.html) covers the 3.x-to-4.0 API changes and the nine behavior changes that don't show up as deprecations or compile errors. - **Methods that take options refuse anything else than an object**: a stray number, string, boolean or `null` as the options of `cropCircle()`, `Image.renderText()`, the encoders (`encodeWebP()`, `encodeAVIF()`, `encodeJXL()`, ...), `quantize()`, `transform()`, `dropShadow()`, `outline()`, the gradients, `Image.decode()`, the animation encoders, and `new GainMap()` throws a `TypeError` that names the working form (e.g. `cropCircle({ size: 'cover' })`), so the 3.x calls that are gone like `cropCircle(true)` or `Image.renderText(font, 32, 'Hi')` fail instead of being ignored. Only the type of the argument is checked: a key that isn't an option is still ignored, so a typo like `{ maxWith: 300 }` isn't caught. @@ -14,7 +14,7 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **`Image.encode()` is removed**: use `image.encodePNG()`, which takes the same arguments. -- **`contain()` and `cover()` are removed** from `Image`, `Frame` and `Animation`: use `resize({ width, height, fit: 'contain' | 'cover' })`. `fit: 'contain'` also takes `background`; `ResizeFit` names the fit modes and `ContainOptions` is removed. 3.x's `contain()` maps to `'inside'`, and 3.x's padded `fit()` maps to `'contain'`. +- **`contain()` and `cover()` are removed** from `Image`, `Frame` and `Animation`: use `resize({ width, height, fit: 'contain' })` or `resize({ width, height, fit: 'cover' })`. `fit: 'contain'` also takes `background`; `ResizeFit` names the fit modes and `ContainOptions` is removed. 3.x's `contain()` maps to `'inside'`, and 3.x's padded `fit()` maps to `'contain'`. - **The color helpers of `Image` are removed**: `Image.rgbaToColor()`, `Image.rgbToColor()`, `Image.hslaToColor()`, `Image.hslToColor()`, `Image.rgbaToHSLA()`, `Image.colorToRGBA()`, `Image.colorToRGB()` and `Image.gradient()` are now `Colors.fromRGBA()`, `Colors.fromRGB()`, `Colors.fromHSLA()`, `Colors.fromHSL()`, `Colors.toHSLA()`, `Colors.toRGBA()`, `Colors.toRGB()` and `Colors.gradient()`. @@ -86,7 +86,7 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - **Values are checked when they are set**: `frame.duration`, `frame.xOffset`, `frame.yOffset` and the parameters of a `GainMap` throw for invalid values (instead of failing in a later encode) and keep their value. -- **An animation has its own canvas size**: decoded animations keep the file's, `new Animation(frames, { width, height })` sets one, and `resize()` sets the new one. Without one it is the frames' extent, as before. Frames (or their parts) outside the canvas aren't encoded. +- **An animation has its own canvas size**: decoded animations keep the file's, `new Animation(frames, { width, height })` sets one (`AnimationCanvasOptions`), and `resize()` sets the new one. Without one it is the frames' extent, as before. Frames (or their parts) outside the canvas aren't encoded. - **The encoders return `Promise>`** instead of `Promise`, so `new Blob()`, `new Response()` and `crypto.subtle` take the output without a cast, also with the stricter DOM types of TypeScript 5.9 and newer. The type needs TypeScript 5.7 or newer. @@ -94,10 +94,10 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation - `Animation.clone()` and `Animation.crop()`. - `resize({ width, height, fit })` on images, frames and animations: `'inside'` fits without padding, `'contain'` pads, and `'cover'` crops while keeping the aspect ratio; `background` sets the bars for `'contain'`. -- `encodeJPEG({ subsampling: '4:4:4' | '4:2:0' })`; the default stays 4:2:0 up to quality 90 and 4:4:4 above. +- `encodeJPEG()` accepts `subsampling: '4:4:4'` or `'4:2:0'`; the default stays 4:2:0 up to quality 90 and 4:4:4 above. - `Colors.gradient()` takes an array of evenly spaced colors, like the `stops` of the gradient functions. - `Animation.scale()`, `Animation.trim()`, `Animation.rotate()`, `Animation.transform()`, `Animation.dropShadow()` and `Animation.outline()`. `scale()` and `trim()` keep the frames; the others work on the composited animation (full canvas frames) and resize the canvas. -- `'contain'` and `'cover'` fit modes of `Frame.resize()`, plus `Frame.rotate()`, `Frame.transform()`, `Frame.dropShadow()` and `Frame.outline()`, which move the offsets like `Frame.resize()` and `Frame.crop()`. +- `Frame.rotate()`, `Frame.transform()`, `Frame.dropShadow()` and `Frame.outline()`, which move the offsets like `Frame.resize()` and `Frame.crop()`. - `Image.encodeGIF()` for single images, with `colors`, `dither` and `ditherStrength` (also on the animation encoders), and the `DitherMethod` type that `quantize()` and the GIF encoders share. - `renderSVG({ fonts })` takes a single font without an array. diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index cd8be8aa..5487b62a 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -124,7 +124,8 @@ animations; frame offsets and animation canvases follow the resized pixels. | `'contain'` | Keeps the aspect ratio and pads to the exact box; `background` sets the bar color | | `'cover'` | Keeps the aspect ratio and crops to the exact box | -`contain` and `cover` require both dimensions. `background` is only valid with `fit: 'contain'`. +`contain` and `cover` require both dimensions. `background` is only valid with `fit: 'contain'`. `ResizeFit` is the +type of the `fit` option. | 3.x | 4.0 | | --- | --- | @@ -161,6 +162,8 @@ JPEG has no alpha channel. In 3.x, `encodeJPEG()` dropped the alpha and kept the showed whatever RGB it had stored (often black, but not always). In 4.0, an image with transparent or translucent pixels is composited onto a background first, black by default, the way a viewer would show it on black. Pass `background` for another color: `image.encodeJPEG({ background: 0xffffffff })`. Opaque images encode as before, byte for byte. +The new `subsampling` option can select `'4:4:4'` or `'4:2:0'`; its default remains 4:2:0 up to quality 90 and 4:4:4 +above. Search your code for: `encodeJPEG(` on images that can have transparency. @@ -244,8 +247,9 @@ any more, so `image.resize(256, 256, { mode: 'RESIZE_BICUBIC' })` throws. | `for (const [x, y] of image)` | `for (const [x, y] of image.coordinates())` | | `image.iterateWithColors()` | `image.pixels()`, which yields `[x, y, color]` | -The color helpers take the same arguments and return the same values, so `Image.rgbaToColor(...)` becomes -`Colors.fromRGBA(...)` and nothing else changes. `Colors` is exported from the package. +The moved color helpers keep their argument shapes and results, except for the hue-unit change described above. +`Colors.gradient()` also accepts an array of evenly spaced colors, in addition to the prior positions-to-colors object. +`Colors` is exported from the package. ### Channel methods and `absolute` @@ -434,7 +438,7 @@ Values that were only checked when they were used are now checked when they are throws a `RangeError` (a `TypeError` for `useBaseColorSpace`) and the old value stays. An `Animation` now has a canvas size of its own: `animation.width` and `animation.height` are the decoded file's, the -one given as `new Animation(frames, { width, height })`, or the one `resize()` and `crop()` set. +one given as `new Animation(frames, { width, height })` (`AnimationCanvasOptions`), or the one `resize()` and `crop()` set. Without one, they are the frames' right and bottom edge, as in 3.x. Frames, or the parts of them, outside the canvas aren't encoded. From 414f4d6be8f6eb87302bb51c5e2484efffa70c56 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Mon, 9 Nov 2026 21:26:37 +0100 Subject: [PATCH 079/188] Fix loop count test timestamp flake --- tests/loop-count.js | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/tests/loop-count.js b/tests/loop-count.js index 0cfed428..41aa6cfc 100644 --- a/tests/loop-count.js +++ b/tests/loop-count.js @@ -11,6 +11,7 @@ const throwsA = (fn, type, label) => { }; const frames = () => [new Frame(new Image(4, 4).fill(0xff0000ff), {duration: 40}), new Frame(new Image(4, 4).fill(0x00ff00ff), {duration: 60})]; +const metadata = {creationTime: 0}; // each format: its encode method, its encoder factory, and the count the file stores when it loops forever or plays // (the GIF's NETSCAPE block counts repetitions, APNG and WebP count plays) const formats = {GIF: ['encodeGIF', 'gifEncoder'], APNG: ['encodeAPNG', 'apngEncoder'], WebP: ['encodeWebP', 'webpEncoder']}; @@ -26,8 +27,8 @@ const netscape = bytes => { if (new Animation(frames()).loopCount !== Infinity || new Animation([]).loopCount !== Infinity) panic('the default loop count should be Infinity'); for (const [format, [encode, factory]] of Object.entries(formats)) { const animation = new Animation(frames()); - const bytes = await animation[encode](); - const encoder = Animation[factory](4, 4); + const bytes = await animation[encode]({metadata}); + const encoder = Animation[factory](4, 4, {metadata}); for (const frame of frames()) await encoder.add(frame); if (!Buffer.from(await encoder.finish()).equals(Buffer.from(bytes))) panic(`${format}: the encoder's default isn't the animation's`); if (encode === 'encodeGIF' && netscape(bytes) !== 0) panic(`${format}: forever should be stored as NETSCAPE count 0, not ${netscape(bytes)}`); @@ -39,9 +40,9 @@ const netscape = bytes => { // Infinity is forever, explicitly, in every entry point for (const [format, [encode, factory]] of Object.entries(formats)) { - const forever = await new Animation(frames(), {loopCount: Infinity})[encode](); - if (!Buffer.from(forever).equals(Buffer.from(await new Animation(frames())[encode]()))) panic(`${format}: Infinity isn't the default`); - const encoder = Animation[factory](4, 4, {loopCount: Infinity}); + const forever = await new Animation(frames(), {loopCount: Infinity})[encode]({metadata}); + if (!Buffer.from(forever).equals(Buffer.from(await new Animation(frames())[encode]({metadata})))) panic(`${format}: Infinity isn't the default`); + const encoder = Animation[factory](4, 4, {loopCount: Infinity, metadata}); for (const frame of frames()) await encoder.add(frame); if (!Buffer.from(await encoder.finish()).equals(Buffer.from(forever))) panic(`${format}: the encoder with Infinity`); const setter = new Animation(frames(), {loopCount: 2}); @@ -52,18 +53,18 @@ const netscape = bytes => { // finite counts: 0 plays once, n repeats n times, through the constructor, the setter and each encoder for (const count of [0, 1, 5]) { for (const [format, [encode, factory]] of Object.entries(formats)) { - const bytes = await new Animation(frames(), {loopCount: count})[encode](); + const bytes = await new Animation(frames(), {loopCount: count})[encode]({metadata}); if (format === 'GIF' && netscape(bytes) !== (count === 0 ? null : count)) panic(`GIF: loop count ${count} stored as ${netscape(bytes)}`); const back = await Animation.decode(bytes); if (back.loopCount !== count) panic(`${format}: loop count ${count} decodes as ${back.loopCount}`); - const encoder = Animation[factory](4, 4, {loopCount: count}); + const encoder = Animation[factory](4, 4, {loopCount: count, metadata}); for (const frame of frames()) await encoder.add(frame); if (!Buffer.from(await encoder.finish()).equals(Buffer.from(bytes))) panic(`${format}: encoder with loop count ${count} differs`); const set = new Animation(frames()); set.loopCount = count; - if (set.loopCount !== count || !Buffer.from(await set[encode]()).equals(Buffer.from(bytes))) panic(`${format}: setter with loop count ${count}`); + if (set.loopCount !== count || !Buffer.from(await set[encode]({metadata})).equals(Buffer.from(bytes))) panic(`${format}: setter with loop count ${count}`); } } @@ -75,11 +76,11 @@ const netscape = bytes => { const limits = {GIF: 65535, WebP: 65534, APNG: 2 ** 31 - 2}; for (const [format, max] of Object.entries(limits)) { const [encode, factory] = formats[format]; - const bytes = await new Animation(frames(), {loopCount: max})[encode](); + const bytes = await new Animation(frames(), {loopCount: max})[encode]({metadata}); if (format === 'GIF' && netscape(bytes) !== max) panic(`GIF: the largest count ${max} is stored as ${netscape(bytes)}`); const back = await Animation.decode(bytes); if (back.loopCount !== max) panic(`${format}: the largest count ${max} decodes as ${back.loopCount}`); - const encoder = Animation[factory](4, 4, {loopCount: max}); + const encoder = Animation[factory](4, 4, {loopCount: max, metadata}); for (const frame of frames()) await encoder.add(frame); if (!Buffer.from(await encoder.finish()).equals(Buffer.from(bytes))) panic(`${format}: the encoder with the largest count ${max} differs`); From 7db8278d9a28b9db581179c93feb0483811e9c1c Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Tue, 10 Nov 2026 18:25:21 +0100 Subject: [PATCH 080/188] fix: differentiate between ICO and CUR --- src/image-type.ts | 12 ++++++++++-- src/image.ts | 2 +- tests/ico.js | 3 ++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/image-type.ts b/src/image-type.ts index 7574b7a1..23467945 100644 --- a/src/image-type.ts +++ b/src/image-type.ts @@ -135,11 +135,19 @@ export class ImageType { } /** - * Whether the data is a Windows icon or cursor (ICO or CUR) + * Whether the data is a Windows icon (ICO) * @param data The file's data */ static isICO(data: BinaryData): boolean { - return iconType(u8(dataView(data))) !== null; + return iconType(u8(dataView(data))) === 'ico'; + } + + /** + * Whether the data is a Windows cursor (CUR) + * @param data The file's data + */ + static isCUR(data: BinaryData): boolean { + return iconType(u8(dataView(data))) === 'cur'; } /** diff --git a/src/image.ts b/src/image.ts index 92adbfc4..3f8898cd 100644 --- a/src/image.ts +++ b/src/image.ts @@ -2601,7 +2601,7 @@ export class Image { if (ImageType.isAVIF(dataView)) return avif.decode(data, maxPixels, toneMapping, threads, gainMap); if (ImageType.isJXL(dataView)) return jxl.decode(data, maxPixels, toneMapping, threads, gainMap); if (ImageType.isBMP(dataView)) return bitmap(bmp.decode(data, maxPixels)); - if (ImageType.isICO(dataView)) return bitmap(ico.decode(data, maxPixels)); + if (ImageType.isICO(dataView) || ImageType.isCUR(dataView)) return bitmap(ico.decode(data, maxPixels)); if (ImageType.isQOI(dataView)) return qoi.decode(data, maxPixels); if (ImageType.isPNM(dataView)) return netpbm.decode(data, maxPixels, toneMapping); if (ImageType.isHDR(dataView)) return hdr.decode(data, maxPixels, toneMapping); diff --git a/tests/ico.js b/tests/ico.js index 251d5ac9..39261be3 100644 --- a/tests/ico.js +++ b/tests/ico.js @@ -32,7 +32,8 @@ const expected = { for (const [name, [type, size, hash]] of Object.entries(expected)) { const binary = await fs.readFile(`./tests/ico/${name}`); if (ImageType.getType(binary) !== type) panic(`${name}: not detected as ${type}`); - if (!ImageType.isICO(binary)) panic(`${name}: isICO should be true`); + if (ImageType.isICO(binary) !== (type === 'ico')) panic(`${name}: isICO should match the file type`); + if (ImageType.isCUR(binary) !== (type === 'cur')) panic(`${name}: isCUR should match the file type`); const image = await Image.decode(binary); if (image.width !== size || image.height !== size) panic(`${name}: ${image.width}x${image.height} instead of ${size}x${size}`); if (sha(image) !== hash) panic(`${name}: decoded pixels don't match`); From bbce672bc5036ff7abb5e37e28634a3f5b70c405 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Tue, 10 Nov 2026 18:27:15 +0100 Subject: [PATCH 081/188] fix(gainmap): return readonly tuples --- src/gainmap.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/gainmap.ts b/src/gainmap.ts index 4c24c205..0246d9c6 100644 --- a/src/gainmap.ts +++ b/src/gainmap.ts @@ -71,21 +71,21 @@ export class GainMap { __useBaseColorSpace__!: boolean; /** log2 of the boost of the map's value 0, per channel (red, green, blue; a copy: assign to change it) */ - get min(): number[] { return this.__min__.slice(); } + get min(): readonly [red: number, green: number, blue: number] { return [this.__min__[0], this.__min__[1], this.__min__[2]]; } /** * Replaces the value (checked: an invalid one throws and the old one stays) * @param value One number for all channels or [r, g, b] */ set min(value: GainMapChannelValues) { this.__min__ = perChannel(value, 'min'); } /** log2 of the boost of the map's value 255, per channel (a copy: assign to change it) */ - get max(): number[] { return this.__max__.slice(); } + get max(): readonly [red: number, green: number, blue: number] { return [this.__max__[0], this.__max__[1], this.__max__[2]]; } /** * Replaces the value (checked: an invalid one throws and the old one stays) * @param value One number for all channels or [r, g, b] */ set max(value: GainMapChannelValues) { this.__max__ = perChannel(value, 'max'); } /** The gamma the map's values are stored with, per channel (a copy: assign to change it) */ - get gamma(): number[] { return this.__gamma__.slice(); } + get gamma(): readonly [red: number, green: number, blue: number] { return [this.__gamma__[0], this.__gamma__[1], this.__gamma__[2]]; } /** * Replaces the value (checked: an invalid one throws and the old one stays) * @param value One number above 0 for all channels or [r, g, b] @@ -96,14 +96,14 @@ export class GainMap { this.__gamma__ = gamma; } /** Added to the SDR image's linear values before the boost, per channel (a copy: assign to change it) */ - get offsetSDR(): number[] { return this.__offsetSDR__.slice(); } + get offsetSDR(): readonly [red: number, green: number, blue: number] { return [this.__offsetSDR__[0], this.__offsetSDR__[1], this.__offsetSDR__[2]]; } /** * Replaces the value (checked: an invalid one throws and the old one stays) * @param value One number for all channels or [r, g, b] */ set offsetSDR(value: GainMapChannelValues) { this.__offsetSDR__ = perChannel(value, 'offsetSDR'); } /** Subtracted from the HDR result, per channel (a copy: assign to change it) */ - get offsetHDR(): number[] { return this.__offsetHDR__.slice(); } + get offsetHDR(): readonly [red: number, green: number, blue: number] { return [this.__offsetHDR__[0], this.__offsetHDR__[1], this.__offsetHDR__[2]]; } /** * Replaces the value (checked: an invalid one throws and the old one stays) * @param value One number for all channels or [r, g, b] From 1d2638a4559d14ef37f33673b17771af9c53a5d1 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Tue, 10 Nov 2026 18:39:40 +0100 Subject: [PATCH 082/188] fix(tiff): pass maxPixels to JPEG decoder --- src/codecs/tiff.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/codecs/tiff.ts b/src/codecs/tiff.ts index 81b03993..646957a1 100644 --- a/src/codecs/tiff.ts +++ b/src/codecs/tiff.ts @@ -205,7 +205,7 @@ export async function decode(data: Uint8Array, maxPixels = MAX_PIXELS): Promise< stream = joined; } - const decoded = jpeg.decode(stream, { transform }); + const decoded = jpeg.decode(stream, { transform, maxPixels }); blitRGBA(decoded.pixels, decoded.width, decoded.height, pixels, width, height, (s % segmentsAcross) * segmentWidth, (s / segmentsAcross | 0) * segmentHeight); } From ff82f626435bea50b02057368103ef097dcd8b57 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Tue, 10 Nov 2026 18:42:17 +0100 Subject: [PATCH 083/188] fix(svg): support maxPixels --- src/image.ts | 20 ++++++++++++++------ src/render/svg.ts | 7 ++++++- tests/option-checks.js | 6 ++++++ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/image.ts b/src/image.ts index 3f8898cd..908b0089 100644 --- a/src/image.ts +++ b/src/image.ts @@ -395,6 +395,11 @@ export interface RenderSVGOptions { * Default: `1` */ scale?: number; + /** + * The most pixels to render, and the most pixels in any embedded image. Default: `268402689` (16383²), `Infinity` + * for no limit + */ + maxPixels?: number; /** * Fonts for `` (TrueType, OpenType): each text gets the face its `font-family`, `font-weight`, `font-style` * and `font-stretch` match best among them, by the names, weights and styles in the fonts (like browsers match @@ -674,13 +679,15 @@ export class Image { * @param height The height in pixels */ constructor(width: number, height: number) { - width = ~~width; - height = ~~height; + width = Math.trunc(finite(width, 'width')); + height = Math.trunc(finite(height, 'height')); if (width < 1) throw new RangeError('Image has to be at least 1 pixel wide'); if (height < 1) throw new RangeError('Image has to be at least 1 pixel high'); + if (width > 0x7fffffff) throw new RangeError('Image width is too large'); + if (height > 0x7fffffff) throw new RangeError('Image height is too large'); this.__width__ = width; this.__height__ = height; @@ -2708,7 +2715,8 @@ export class Image { static renderSVG(svg: string | BinaryData, options?: RenderSVGOptions): Promise; static async renderSVG(svg: string | BinaryData, options: RenderSVGOptions = {}): Promise { checkOptions(options, 'renderSVG(svg, { width: 512 })'); - const { width: givenWidth, height: givenHeight, scale, fonts = [], images: givenImages = {} } = options; + const { width: givenWidth, height: givenHeight, scale, maxPixels = MAX_PIXELS, fonts = [], images: givenImages = {} } = options; + if (!(Number(maxPixels) > 0)) throw new RangeError(`maxPixels must be a positive number, not ${maxPixels}`); const sizes = [givenWidth, givenHeight, scale].filter(value => value !== undefined).length; if (sizes > 1) throw new TypeError('renderSVG() takes one of width, height and scale'); const mode = givenWidth !== undefined ? 'width' : givenHeight !== undefined ? 'height' : 'scale'; @@ -2741,10 +2749,10 @@ export class Image { images.set(href, { width: value.width, height: value.height, pixels: value.bitmap }); } else if (ImageType.getType(value) === 'gif') { // (a GIF's first frame) - const decoded = gif.decode(view(value), true); + const decoded = gif.decode(view(value), true, maxPixels); images.set(href, { width: decoded.width, height: decoded.height, pixels: gif.composite(decoded)[0].pixels }); } else { - const image = await Image.decode(value); + const image = await Image.decode(value, { maxPixels }); images.set(href, { width: image.width, height: image.height, pixels: image.bitmap }); } } catch { @@ -2752,7 +2760,7 @@ export class Image { } } - const {width, height, pixels} = svgRenderer.render(source, mode, size, images, (Array.isArray(fonts) ? fonts : [fonts]).map(font => view(font))); + const {width, height, pixels} = svgRenderer.render(source, mode, size, images, (Array.isArray(fonts) ? fonts : [fonts]).map(font => view(font)), maxPixels); const image = new Image(width, height); image.bitmap.set(pixels); diff --git a/src/render/svg.ts b/src/render/svg.ts index 5e421eb5..d003d377 100644 --- a/src/render/svg.ts +++ b/src/render/svg.ts @@ -22,6 +22,7 @@ import { FontFace, FontStyle, fontFace, layOutText, parseFontFamilies, parseVari import { rasterizeGlyph } from './text.ts'; import { renderPaintGlyph } from './colr.ts'; import { decodeSync as decodePNG } from '../codecs/png.ts'; +import { MAX_PIXELS, checkPixels } from '../util/limits.ts'; const UNSUPPORTED = new Set(['filter', 'pattern', 'marker', 'foreignObject', 'script', 'style', 'title', 'desc', 'metadata', 'defs', 'clipPath', 'mask', 'linearGradient', 'radialGradient', 'stop', 'symbol', 'font', 'font-face', 'animate', 'animateTransform', 'animateMotion', 'set']); @@ -159,7 +160,7 @@ const OFFSETS = Float64Array.from({ length: SAMPLES }, (_, s) => (s + 0.5) / SAM * @param {number} size * @return {{width: number, height: number, pixels: Uint8Array}} */ -export function render(source: string | Uint8Array, mode: 'scale' | 'width' | 'height', size: number, images: Map = new Map(), fonts: Uint8Array[] = []): { width: number, height: number, pixels: Uint8Array } { +export function render(source: string | Uint8Array, mode: 'scale' | 'width' | 'height', size: number, images: Map = new Map(), fonts: Uint8Array[] = [], maxPixels = MAX_PIXELS): { width: number, height: number, pixels: Uint8Array } { if (typeof source !== 'string') source = decodeUTF8(source); const root = parseXML(source).children.find(node => node.name === 'svg' && node.svg); @@ -186,6 +187,10 @@ export function render(source: string | Uint8Array, mode: 'scale' | 'width' | 'h outWidth = Math.max(1, outWidth); outHeight = Math.max(1, outHeight); + if (!Number.isSafeInteger(outWidth) || !Number.isSafeInteger(outHeight)) + throw new RangeError(`SVG output dimensions are too large: ${outWidth}x${outHeight}`); + checkPixels(outWidth, outHeight, maxPixels); + const canvas = new Layer(outWidth, outHeight); // (a transform on the root element (SVG 2) applies before the viewBox mapping) doc.renderRoot([outWidth / width, 0, 0, outHeight / height, 0, 0], canvas, 0); diff --git a/tests/option-checks.js b/tests/option-checks.js index 9e8580f5..3b1098fe 100644 --- a/tests/option-checks.js +++ b/tests/option-checks.js @@ -55,7 +55,13 @@ const rejects = async (name, fn, Type, pattern) => { await rejects('Animation loopCount 1.5', () => new Animation([new Frame(new Image(8, 8))], {loopCount: 1.5}), RangeError, /loop count/); await rejects('dominantColor threshold NaN', () => image.dominantColor({threshold: NaN}), RangeError, /threshold/); await rejects('renderSVG scale NaN', () => Image.renderSVG('', {scale: NaN}), RangeError, /SVG scale/); + await rejects('renderSVG maxPixels 0', () => Image.renderSVG('', {maxPixels: 0}), RangeError, /maxPixels/); + await rejects('renderSVG maxPixels exceeded', () => Image.renderSVG('', {maxPixels: 15}), RangeError, /maxPixels/); + if ((await Image.renderSVG('', {maxPixels: 16})).width !== 4) + panic('renderSVG: maxPixels should allow an output that fits'); await rejects('renderText size -1', () => Image.renderText('x', { font: new Uint8Array(0), size: -1 }), RangeError, /font size/); + if (new Image(2.9, 1).width !== 2) panic('Image constructor: fractional dimensions should still be truncated'); + await rejects('Image width wraps to small positive dimension', () => new Image(2 ** 32 + 2, 1), RangeError, /width is too large/); const png = await image.encodePNG(); for (const maxPixels of [NaN, -1, 0]) { await rejects(`Image.decode maxPixels ${maxPixels}`, () => Image.decode(png, {maxPixels}), RangeError, /maxPixels/); From 6291ef1ff00510cff663b300e02271bf97485d01 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Tue, 10 Nov 2026 20:55:18 +0100 Subject: [PATCH 084/188] rework docs --- README.md | 29 +++--- bench/README.md | 28 +++--- guides/Migrating to 4.0.md | 24 +++-- src/animation.ts | 14 +-- src/gainmap.ts | 32 ++++--- src/image.ts | 183 +++++++++++++++++-------------------- src/render/svg.ts | 3 +- 7 files changed, 159 insertions(+), 154 deletions(-) diff --git a/README.md b/README.md index f189ca6a..186fcc27 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,11 @@ --- -**ImageScript** decodes, edits and encodes images in pure TypeScript: no WebAssembly, no native binaries, no -dependencies. It runs in Node.js 22.13+, Deno, Bun, browsers and edge runtimes (Cloudflare Workers, Vercel's Edge -Runtime, Deno Deploy), works with both `import` and `require()`, and ships with type definitions. Large images are -encoded and decoded on several threads wherever the runtime supports workers. +**ImageScript** decodes, edits and encodes images in pure TypeScript: no WebAssembly, no native binaries, and no +runtime dependencies. It runs in Node.js 22.13+, Deno, Bun, browsers and edge runtimes (Cloudflare Workers, Vercel's +Edge Runtime, Deno Deploy), works with `import` and, on supported Node.js versions, `require()`, and ships with type +definitions. Large AVIF and JPEG XL operations, and lossless WebP encoding, can use multiple CPU cores when the runtime +supports it. ```sh npm install imagescript # Node.js, Bun and bundlers @@ -29,7 +30,7 @@ Without a bundler, import it from jsDelivr in the browser: ```html Time per operation of the libraries that run everywhere, colored by the distance to the fastest @@ -53,7 +52,8 @@ Time per operation (less is faster), measured 2026-11-08 with Node.js v24.22.2 o | render SVG | **172 ms** | – | – | – | – | 43 ms | – | 19 ms | | thumbnail, keeping HDR | **180 ms** | – | – | – | – | 167 ms | – | – | -Encoded sizes: +Encoded sizes. For lossy files, the dB score estimates how closely the decoded image matches the source; higher is +closer. | | **ImageScript** | **Jimp** | **image-js** | **PureJsImage** | **Photon** | **sharp** | **jsquash** | **@napi-rs/canvas** | |---|--:|--:|--:|--:|--:|--:|--:|--:| @@ -87,10 +87,11 @@ Encoded sizes: when the benchmark starts, PNG, lossy WebP, AVIF by sharp (libaom) and JPEG XL by jsquash (libjxl), at the qualities below), a 1434×1434 graphic with transparency (PNG), a 445×445 diagram with transparency placed on the photo (PNG), a 300×300 GIF with 101 frames (and made from it, an animated WebP by sharp at quality 80). -- Lossy WebP, AVIF and JPEG XL are encoded at the same nominal quality (80, 60 and 90); as the libraries' - scales differ, the file sizes come with their PSNR (of the color over black, decoded by libwebp, libavif and - libjxl). jsquash's libjxl runs at the effort ImageScript's default matches (cjxl's 5 lossy, 3 lossless; its own - default is 7). PureJsImage's AVIF +- Lossy WebP, AVIF and JPEG XL are encoded at the same nominal quality (80, 60 and 90). Because each library uses a + different quality scale, file sizes are shown with PSNR (peak signal-to-noise ratio), a measure of how closely the + decoded colors match the original; higher is closer. Colors are compared over black after decoding with libwebp, + libavif and libjxl. jsquash's JPEG XL encoder uses effort settings chosen to match ImageScript's defaults. + PureJsImage's AVIF encoder has no quality option, and its JPEG XL decoder doesn't support everything libjxl's files use, which shows as failing. jsquash only encodes and decodes AVIF and JPEG XL (it gets the other inputs as pixels); @napi-rs/canvas decodes both with Skia's codecs, but doesn't encode them. @@ -101,9 +102,10 @@ Encoded sizes: clone first, sharp runs a pipeline from raw pixels back to raw pixels, PureJsImage (which has no decoded images) one from a BMP back to a BMP, @napi-rs/canvas draws into a new canvas. PureJsImage has no blur, composite, rendering or animated GIFs. -- Where the library lets you choose, resizing is bilinear (or bicubic, in its own row), the thumbnail covers 400×400 - (cropping the sides), blurring is gaussian with a radius or sigma of 5, sharpening an unsharp mask of radius or - sigma 1 (Photon: its 3×3 sharpening kernel), rotating by 30° grows the image to fit, brightness and contrast go up +- Where the library lets you choose, resizing is bilinear (smooth) or bicubic (a sharper smoothing filter, in its own + row), the thumbnail covers 400×400 (cropping the sides), blurring is gaussian (softening) with a radius or sigma of + 5, sharpening uses an unsharp mask (edge enhancement) of radius or sigma 1 (Photon: its 3×3 sharpening kernel), + rotating by 30° grows the image to fit, brightness and contrast go up 20% (Jimp's and Photon's scales converted). image-js's and sharp's greyscale make one grey channel (and alpha). Each library uses its own defaults otherwise, e.g. its PNG compression level. - Threads: ImageScript uses worker threads where they pay off (the processors available, at most 8), and sharp gets @@ -111,7 +113,7 @@ Encoded sizes: libraries run on one thread (jsquash turns its threaded builds off in Node). - [tinybench](https://github.com/tinylibs/tinybench), at least 1.5 seconds and 3 runs per measurement, every library in its own process. -- The release workflow runs the benchmark for every release and commits the results, on a GitHub Actions runner; +- The release workflow runs the benchmark for every regular release and commits the results, on a GitHub Actions runner; runners vary, so compare libraries within one run rather than between runs. - Install sizes: `npm install ` in an empty project, the apparent size of `node_modules`. diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md index 5487b62a..125da5a5 100644 --- a/guides/Migrating to 4.0.md +++ b/guides/Migrating to 4.0.md @@ -158,19 +158,22 @@ Search your code for: `.rotate(`. ### JPEG composites transparent pixels onto black -JPEG has no alpha channel. In 3.x, `encodeJPEG()` dropped the alpha and kept the color, so a fully transparent pixel -showed whatever RGB it had stored (often black, but not always). In 4.0, an image with transparent or translucent pixels -is composited onto a background first, black by default, the way a viewer would show it on black. Pass `background` -for another color: `image.encodeJPEG({ background: 0xffffffff })`. Opaque images encode as before, byte for byte. -The new `subsampling` option can select `'4:4:4'` or `'4:2:0'`; its default remains 4:2:0 up to quality 90 and 4:4:4 -above. +JPEG does not store transparency (also called an alpha channel). In 3.x, `encodeJPEG()` dropped the alpha and kept the +color, so a fully transparent pixel showed its stored red, green and blue values (often black, but not always). In +4.0, an image with transparent or translucent pixels is combined with a background first, black by default, the way +a viewer would show it on black. Pass `background` for another color: `image.encodeJPEG({ background: 0xffffffff })`. +Opaque images encode as before, byte for byte. +The new `subsampling` option controls how much color detail to keep: `'4:4:4'` keeps it all; `'4:2:0'` stores less +color detail to make a smaller file. Its default remains 4:2:0 up to quality 90 and 4:4:4 above. Search your code for: `encodeJPEG(` on images that can have transparency. ### `quantize()` dithers by default -`image.quantize()` now dithers with Floyd-Steinberg at a strength of `0.85` by default, like the GIF encoders. In 3.x -it didn't dither unless you passed `dither`. For the old result, pass `dither: 'none'`: +`image.quantize()` reduces an image to a limited color palette. It now uses dithering by default: a pattern of small +color changes that helps smooth the result when colors are reduced. The default method is Floyd-Steinberg, with a +strength of `0.85`, like the GIF encoders. In 3.x it didn't dither unless you passed `dither`. For the old result, +pass `dither: 'none'`: | 3.x | 4.0 | | --- | --- | @@ -279,8 +282,9 @@ image.red(0.5, true); for (let i = 0; i < image.bitmap.length; i += 4) image.bitmap[i] = 0.5 * 255; ``` -`bitmap` is RGBA, four bytes per pixel. Writing it directly skips the gain map of an HDR image, so do not use it on -images that have one. `absolute` on `lightness()` and `saturation()` sets the value of every pixel and keeps its hue. +`bitmap` stores four bytes per pixel: red, green, blue and transparency (alpha). Writing it directly skips the gain map +of a high-dynamic-range (HDR) image, so do not use it on images that have one. `absolute` on `lightness()` and +`saturation()` sets the value of every pixel and keeps its hue. Do it per pixel: read the color with `Colors.toHSLA()`, set the lightness or saturation, and write the pixel back with `Colors.fromHSLA()`. diff --git a/src/animation.ts b/src/animation.ts index bd8e2e42..5e2090bd 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -60,16 +60,18 @@ export interface AnimationEncodeOptions extends EncodeOptions { /** Options of {@link Animation.encodeWebP} */ export interface WebPAnimationEncodeOptions extends AnimationEncodeOptions { /** - * Lossy frames (0-100, like libwebp's quality: 75 is its default), much smaller than lossless ones. Without it the - * frames are lossless. Optimized lossy frames, like libwebp's, leave out 8x8 blocks that only changed a little (by - * up to 3 per channel at quality 85, 1 at 100) + * Lossy compression quality (0-100): higher values usually preserve more detail but make larger files. Without + * this option, frames are encoded losslessly */ quality?: number; - /** How much effort the frames take (0-6, lossy and lossless), see {@link WebPEncodeOptions.effort}. Default: `2` */ + /** + * Compression effort (0-6): higher effort can make smaller files but takes longer, see + * {@link WebPEncodeOptions.effort}. Default: `2` + */ effort?: number; - /** Lossy frames: libwebp's sharp YUV conversion, see {@link WebPEncodeOptions.sharpYuv}. Default: `false` */ + /** For lossy frames, improve color detail around sharp edges. Default: `false` */ sharpYuv?: boolean; - /** Lossy frames: the alpha planes' quality (0-100), see {@link WebPEncodeOptions.alphaQuality}. Default: `100` */ + /** For lossy frames, the quality of the transparency channel (0-100). Default: `100` */ alphaQuality?: number; } diff --git a/src/gainmap.ts b/src/gainmap.ts index 0246d9c6..b3ac6d49 100644 --- a/src/gainmap.ts +++ b/src/gainmap.ts @@ -15,21 +15,24 @@ export type GainMapChannelValues = number | readonly [red: number, green: number /** Options of the {@link GainMap} constructor */ export interface GainMapOptions { - /** log2 of the boost of the map's value 0 (`-1`: half as bright; hdrgm's GainMapMin). Default: `0` */ + /** Extra brightness at the map's darkest value (0); `-1` means half as bright. Default: `0` (no change) */ min?: GainMapChannelValues; - /** log2 of the boost of the map's value 255 (`2`: 4 times as bright; hdrgm's GainMapMax). Default: `2` */ + /** Extra brightness at the map's brightest value (255); `2` means four times as bright. Default: `2` */ max?: GainMapChannelValues; - /** the gamma the map's values are stored with. Default: `1` */ + /** How quickly the brightness changes between `min` and `max`; `1` changes it evenly. Default: `1` */ gamma?: GainMapChannelValues; - /** added to the SDR image's linear values before the boost (keeps black from being boosted forever). Default: `1/64` */ + /** + * Small brightness offset added before applying the boost, to keep black from being boosted indefinitely. + * Default: `1/64` + */ offsetSDR?: GainMapChannelValues; - /** subtracted from the HDR result. Default: `1/64` */ + /** Small brightness offset subtracted from the result. Default: `1/64` */ offsetHDR?: GainMapChannelValues; - /** log2 of the display headroom from which on the map starts to apply. Default: `0` */ + /** How bright a display must be before the map starts to apply; `0` is standard-screen brightness. Default: `0` */ hdrCapacityMin?: number; - /** log2 of the display headroom at which it applies fully. Default: `max`'s largest value */ + /** How bright a display must be for the map to apply fully. Default: the largest value in `max` */ hdrCapacityMax?: number; - /** whether the boost applies in the SDR image's color space (`false`: in the HDR image's). Default: `true` */ + /** Whether the boost uses the original image's colors (`false`: the brighter HDR colors). Default: `true` */ useBaseColorSpace?: boolean; } @@ -41,13 +44,14 @@ const perChannel = (value: GainMapChannelValues, name: string): number[] => { }; /** - * A gain map: a small image saying how much brighter each part of an image gets on an HDR display, as Ultra HDR - * (JPEG-R) photos from phones carry it. HDR displays show the image boosted by it, the others the image as it is. + * A gain map is a small companion image that tells a high-dynamic-range (HDR) display how much brighter to show each + * part of a photo. Standard displays ignore it and show the regular-brightness image. * - * {@link Image.decode} reads the gain maps of JPEGs, AVIFs and JPEG XLs into {@link Image.gainMap}, - * {@link Image.encodeJPEG}, {@link Image.encodeAVIF} and {@link Image.encodeJXL} write them. The map covers the whole image, at any resolution (phones - * write a quarter of the width and height). Its value `v` (0-255, per channel or one for all) boosts the linear - * light of the image by `2^(min + (max - min) * (v / 255)^(1 / gamma))`. + * {@link Image.decode} reads gain maps from supported JPEG, AVIF and JPEG XL images into {@link Image.gainMap}; + * {@link Image.encodeJPEG}, {@link Image.encodeAVIF} and {@link Image.encodeJXL} can write them. The map covers the + * whole image, at any resolution. Each pixel's value (0-255) controls how much extra brightness to add at that + * location. For developers who need the exact calculation, the boost is + * `2^(min + (max - min) * (v / 255)^(1 / gamma))`, per color channel. * * The parameters are checked when they are set: a value that isn't finite (or an array of other than 1 or 3 of them) * throws, and the map keeps its value diff --git a/src/image.ts b/src/image.ts index 908b0089..0809b917 100644 --- a/src/image.ts +++ b/src/image.ts @@ -120,12 +120,8 @@ export interface ResizeOptions { /** The resizing mode. Default: `'bilinear'` */ mode?: ResizeMode; /** - * Whether to round to 8 bits between the two passes, so two channels share the multiplications of both: about - * 15-25% faster for opaque images, with 10-25% of the channel values a step off the precise result (instead of - * about 1%, never more than 1). Transparent images are premultiplied to 8 bits like browsers do, which makes them - * about twice as fast: what shows once they're composited differs by up to about 2 steps, pixels that are almost - * transparent keep less of their color. Only affects `'bilinear'` and `'area'`, - * down to about a third of the size. Default: `true` + * Whether to use faster, lower-precision arithmetic. It can cause small color differences, especially around + * transparent edges. Only affects `'bilinear'` and `'area'` resizing. Default: `true` */ fast?: boolean; } @@ -139,36 +135,34 @@ export interface SharpenOptions { } /** - * How the rounding error of each pixel is handled when colors are reduced to a palette ({@link Image.quantize}, - * {@link Image.encodeGIF}): `'none'` (each pixel takes its nearest color), `'floyd-steinberg'` (the error goes to the next - * pixels, 7/16, 3/16, 5/16 and 1/16), `'atkinson'` (the error goes to six neighbors, 1/8 each: lighter, more contrast), - * `'ordered'` (a regular 4x4 pattern of thresholds, no error spreading). Rows alternate direction for the error - * diffusions + * How to reduce visible banding when an image is limited to a smaller set of colors ({@link Image.quantize}, + * {@link Image.encodeGIF}): `'none'` uses the nearest color; `'floyd-steinberg'` and `'atkinson'` spread small color + * errors to nearby pixels; `'ordered'` uses a repeating pattern. Atkinson tends to give a higher-contrast result. */ export type DitherMethod = 'none' | 'floyd-steinberg' | 'atkinson' | 'ordered'; /** Options of {@link Image.quantize}: either `colors` or `palette`, and the dithering */ export interface QuantizeOptions { /** - * The number of colors to reduce the image to, an integer from 2 to 256. The palette is the image's own colors when - * it has no more distinct ones than this, else colors chosen by median cut (the common colors get their own). Those - * are averages of groups of similar colors, so e.g. pure black and white are only in it if the image has them in - * quantity: give `palette` for exact colors. Default: `256`. Give `colors` or `palette`, not both + * The maximum number of colors to keep (2-256). Images with fewer distinct colors keep them exactly; otherwise + * ImageScript chooses a smaller set of representative colors. Give `palette` instead to choose exact colors. + * Default: `256`. Give `colors` or `palette`, not both */ colors?: number; /** - * A fixed palette (1 to 256 colors, e.g. a Game Boy's four greens) that every pixel is mapped to, the nearest color - * by its RGB. The alpha of the palette's colors is ignored. Give `palette` or `colors`, not both + * A fixed palette (1-256 colors, e.g. a Game Boy's four greens). Every pixel is changed to the nearest palette + * color by its red, green and blue values; transparency in palette colors is ignored. Give `palette` or `colors`, + * not both */ palette?: Color[]; /** - * How the rounding error of each pixel is handled, which makes gradients look smooth with few colors, see + * How to spread small color changes across nearby pixels to make gradients look smoother with few colors, see * {@link DitherMethod}. Default: `'floyd-steinberg'`, like {@link GIFEncodeOptions.dither} */ dither?: DitherMethod; /** - * How strongly to dither, from `0` (no spreading or threshold, the same as `'none'`) to `1` (the full error or - * threshold). Default: `0.85`, like {@link GIFEncodeOptions.ditherStrength} + * How strongly to apply dithering: `0` disables it; `1` applies it fully. Default: `0.85`, like + * {@link GIFEncodeOptions.ditherStrength} */ ditherStrength?: number; } @@ -300,8 +294,8 @@ export type TGAEncodeOptions = EncodeOptions; /** Options of {@link Image.encodeGIF}, and of {@link Animation.encodeGIF} with {@link GIFAnimationEncodeOptions} */ export interface GIFEncodeOptions extends EncodeOptions { /** - * The most colors in the palette (of each frame), an integer from 2 to 256, a transparent one included. Images with - * no more colors keep them exactly, others get a palette by median cut. Default: `256` + * The maximum colors in each frame's palette (2-256, including transparency). Frames with fewer colors keep them + * exactly; otherwise ImageScript chooses representative colors. Default: `256` */ colors?: number; /** How the rounding error is spread when the colors don't fit the palette, see {@link DitherMethod}. Default: `'floyd-steinberg'` */ @@ -315,13 +309,14 @@ export interface JPEGEncodeOptions extends EncodeOptions { /** The JPEG quality (1-100). Default: `90` */ quality?: number; /** - * Chroma subsampling: `'4:4:4'` keeps the full color resolution, `'4:2:0'` halves it (smaller, like most - * cameras). Default: `'4:2:0'` up to quality 90, `'4:4:4'` above (like libjpeg-turbo's `cjpeg`) + * How much color detail to keep: `'4:4:4'` keeps it all; `'4:2:0'` stores less color detail to make a smaller + * file (often hard to notice in photos, but more visible around colored edges). Default: `'4:2:0'` up to quality + * 90, `'4:4:4'` above */ subsampling?: '4:4:4' | '4:2:0'; /** - * The color that transparent and translucent pixels are composited onto, as JPEG has no transparency (its alpha is - * ignored: the background is opaque). Opaque images are encoded as they are. Default: `0x000000ff` (black) + * The background color to combine with transparent pixels, since JPEG doesn't support transparency. Opaque images + * are encoded as they are. Default: `0x000000ff` (black) */ background?: Color; /** @@ -334,37 +329,28 @@ export interface JPEGEncodeOptions extends EncodeOptions { /** Options of {@link Image.encodeWebP} */ export interface WebPEncodeOptions extends EncodeOptions { /** - * Lossy compression (0-100, like libwebp's quality: 75 is its default): the image is encoded like cwebp encodes it - * (with `-m` set to the effort), much smaller than lossless. Without it the image is encoded losslessly + * Lossy compression (0-100): higher values usually preserve more detail but make larger files. Without it the image + * is encoded losslessly, keeping every pixel exact */ quality?: number; /** - * How much effort compression takes (0-6): higher is smaller but slower. Lossy (libwebp's method): 0-2 pick the - * predictions by how close they are, 3 and up also by how many bits they take, 5 and 6 also optimize the - * quantized values; the default `2` takes about a quarter of the time of 4 (cwebp's default) for files about 5% - * larger. Lossless: 0 is a fast mode (photos in about a fifth of the time, graphics in about a third, files about - * 6-8% larger), 1 takes about two thirds of the time for files up to 2% larger, 2 and up compress the most. - * Default: `2` + * Compression effort (0-6): higher values usually take longer but may make smaller files. For lossless images, + * `0` prioritizes speed; `2` and above spend more time reducing file size. Default: `2` */ effort?: number; /** - * For lossy compression: convert to YUV like libwebp's sharp conversion (cwebp's `-sharp_yuv`, the same file), - * which picks the color planes so that colored edges (red text on blue, thin colored lines) stay sharp instead of - * bleeding. It takes about twice the time at the default effort; photos get about as large, images with many - * hard color edges a lot larger (1200x800 red and blue squares: 2.7 times). Default: `false` + * For lossy compression: improve color detail around sharp edges (for example, red text on a blue background). + * This can take longer and make graphics larger. Default: `false` */ sharpYuv?: boolean; /** - * For lossy compression, the alpha plane's quality (0-100, like cwebp's `-alpha_q`): below 100 its levels are - * reduced (to 2-16 up to 70, 16-256 above), which makes it compress better; the alpha plane is lossless otherwise. - * Default: `100` + * For lossy compression, the transparency (alpha) channel's quality (0-100). Lower values may make the image + * smaller but can make transparency look less smooth. Default: `100` (full quality) */ alphaQuality?: number; /** - * For lossless compression, at most this many worker threads search the predictors and prefix codes in parallel - * (the same file, faster), `1` for none. Images under about 1.5 megapixels, and runtimes whose workers can't share - * memory (browsers without cross-origin isolation), encode without them. Default: the processors available, at - * most 8 + * For lossless compression, the maximum worker threads to use. Small images or runtimes that cannot share memory + * between workers use one thread. Default: the available processors, up to 8 */ threads?: number; } @@ -396,12 +382,14 @@ export interface RenderSVGOptions { */ scale?: number; /** - * The most pixels to render, and the most pixels in any embedded image. Default: `268402689` (16383²), `Infinity` - * for no limit + * Maximum total pixels in the rendered output. Output over the limit throws a RangeError. Raster images decoded + * from embedded data are limited too and omitted if they exceed it; already-decoded {@link Image} values are used + * as supplied. Default: `268402689` (16383²); `Infinity` disables the limit */ maxPixels?: number; /** - * Fonts for `` (TrueType, OpenType): each text gets the face its `font-family`, `font-weight`, `font-style` + * Fonts for `` (TrueType, OpenType or WOFF; not WOFF2 or some newer OpenType outline formats such as CFF2): + * each text gets the face its `font-family`, `font-weight`, `font-style` * and `font-stretch` match best among them, by the names, weights and styles in the fonts (like browsers match * installed fonts). Generic families (`serif`, `sans-serif`, `monospace`, ...) are the first font that fits them, * other families and text without one get the first font's family; characters a face doesn't have come from the @@ -426,10 +414,12 @@ export interface RenderSVGOptions { */ export interface RenderTextOptions extends TextStyleOptions, TextLayoutOptions { /** - * TrueType (ttf/ttc), OpenType (otf) or WOFF font buffer to use, or several by priority: every character comes + * TrueType (ttf/ttc), OpenType (otf) or WOFF font buffer to use (not WOFF2 or some newer OpenType outline formats + * such as CFF2), or several by priority: every character comes * from the first font that has it (e.g. `[text, emoji]`), the first one sets the line height. Color glyphs (COLR - * layers and paints, CBDT and sbix bitmaps, as in emoji fonts) keep their colors. Text in any script is shaped - * like browsers do (right to left and bidirectional text, Arabic joining, Indic conjuncts, marks) + * layers and paints, CBDT and sbix bitmaps, as in emoji fonts) keep their colors. Shaping includes bidirectional + * layout, Arabic joining, and script-specific OpenType shapers for Indic scripts, Hebrew, Thai/Lao, Hangul, Khmer, + * Myanmar and other Universal Shaping Engine scripts; other scripts use default OpenType shaping. */ font: BinaryData | BinaryData[]; /** Font size in pixels (per em) */ @@ -441,42 +431,40 @@ export interface RenderTextOptions extends TextStyleOptions, TextLayoutOptions { /** Options of {@link Image.decode}, {@link Animation.decode} and {@link decode} */ export interface DecodeOptions { /** - * Whether to rotate/mirror the image (or every frame) as its orientation tag says: Exif in JPEGs, TIFFs, PNGs - * (also eXIf and XMP, and in APNGs) and WebPs (also animated ones), and JPEG XL's header. Default: `true` + * Whether to rotate or mirror the image according to its saved orientation information, so photos taken with a + * rotated camera display upright. This applies to every animation frame too. Default: `true` */ applyOrientation?: boolean; /** - * Whether to convert the colors of images with an embedded ICC profile (PNG, JPEG, WebP, AVIF, JPEG XL, TIFF), and - * of AVIFs with other color primaries than sRGB's (Display P3, BT.2020), to sRGB, like browsers show them: wide - * gamut photos (Display P3 from phones, Adobe RGB, ProPhoto) look as intended instead of washed out. Matrix/TRC - * profiles (RGB and gray) are applied, relative colorimetric (colors outside sRGB are clipped); profiles with only - * lookup tables, CMYK ones and ones that are sRGB anyway leave the pixels as they are. Default: `true` + * Whether to use the image's embedded color profile (color information describing its pixels) to convert its + * colors to sRGB, the standard used by most screens and browsers. Common RGB and gray profiles described by a + * color matrix and tone curves are converted, as are AVIFs using other color ranges (such as Display P3 or BT.2020). + * Colors outside sRGB are clipped. Profiles that rely on lookup tables, CMYK profiles and profiles already in + * sRGB are left unchanged. Default: `true` */ applyColorProfile?: boolean; /** - * The most pixels to decode: larger images throw a RangeError before anything that size is allocated, so small - * files declaring huge images (decompression bombs) can't exhaust the memory. For {@link Animation.decode}, all - * frames together (every frame is a full canvas). Default: `268402689` (16383², like sharp), `Infinity` for no - * limit + * The maximum total pixels to decode. An image declaring more pixels throws a RangeError before its pixel buffer + * is allocated, helping prevent a small or untrusted file from requesting an enormous amount of memory. + * {@link Animation.decode} counts all frames together because each frame uses a full canvas. Default: + * `268402689` (16383²); `Infinity` disables the limit */ maxPixels?: number; /** - * How Radiance HDRs, PFMs and HDR (PQ or HLG) AVIFs and JPEG XLs, whose light can be brighter than (SDR) white, - * become 8 bit sRGB: `'reinhard'` compresses the brightest parts like Reinhard's tone mapping operator does (the - * brightest pixel becomes white, images no brighter than white stay as they are), `'clamp'` clamps everything - * brighter than white. Default: `'reinhard'` + * How high-dynamic-range images (which can be brighter than a standard screen's white) become 8-bit sRGB. + * `'reinhard'` gently compresses bright highlights so the brightest pixel becomes white; images that are not HDR + * stay unchanged. `'clamp'` clips anything brighter than white. Default: `'reinhard'` */ toneMapping?: ToneMapping; /** - * Whether to read the gain maps of JPEGs (Ultra HDR, JPEG-R, ISO 21496-1, Apple's), AVIFs (tone mapped items) and - * JPEG XLs (jhgm boxes, also of every frame of an animation) into {@link Image.gainMap}. Default: `true` + * Whether to read extra HDR brightness data (a gain map) from supported JPEG, AVIF and JPEG XL images into + * {@link Image.gainMap}. For animations, this also reads maps from supported JPEG XL frames. Default: `true` */ gainMap?: boolean; /** - * At most this many worker threads decode in parallel (lossy JPEG XLs from 4 megapixels on, one per 2 megapixels - * at most, lossless ones from 1.5 megapixels and 512 KiB on; AVIFs from 4 megapixels on: the conversion to RGB, - * one per megapixel, and the tiles of grids), `1` for none. Default: the processors available, at most 8 (1 where - * workers can't share memory) + * Maximum worker threads for formats and image sizes that can be decoded in parallel. Small images may use one + * thread because starting workers can take longer than decoding them. Default: the available processors, up to 8 + * (one in runtimes that cannot share memory between workers) */ threads?: number; } @@ -484,30 +472,23 @@ export interface DecodeOptions { /** Options of {@link Image.encodeJXL} */ export interface JXLEncodeOptions extends EncodeOptions { /** - * Lossy compression (0-100, like cjxl's `-q`: 90 is visually lossless, 100 lossless): higher is better and larger. - * Without it the image is encoded losslessly + * Lossy compression (0-100): higher values usually preserve more detail but make larger files; `100` is + * lossless. Without this option, the image is encoded losslessly */ quality?: number; /** - * How hard compression works: 0-2, faster to smaller. Lossy: 0 uses 8x8 DCTs only (like `cjxl -e 3`), 1 and 2 - * search their sizes like `cjxl -e 5` (2 tries more of the small ones), and 2 also stores text-like features that - * repeat (the glyphs of screenshots) once, as patches like `cjxl -e 7`. Lossless: 0 is `cjxl -e 1`'s fast mode - * (byte for byte), 1 and 2 like `cjxl -e 3` (about 15% smaller for photos, 13% for graphics; a few times slower). - * Default: `1` + * Compression effort (0-2): higher values usually take longer but may make smaller files. `0` prioritizes speed; + * `2` spends the most time compressing. Default: `1` */ effort?: number; /** - * At most this many worker threads compress in parallel (the same file, faster: lossy 2.3 times with 8 on a - * 1920x1080 photo at effort 1, 3.8 times on 3840x2160; lossless 2.8 and 4.3 times), `1` for none. Small images - * (lossy under about 1.5 megapixels at effort 0, 512x512 pixels at 1, 384x384 at 2; lossless under 724x724 and - * always at effort 0), and runtimes whose workers can't share memory (browsers without cross-origin isolation) - * encode without them. - * Default: the processors available, at most 8 + * Maximum worker threads for compression. Small images or runtimes that cannot share memory between workers use + * one thread. Default: the available processors, up to 8 */ threads?: number; /** - * Whether to write the image's {@link Image.gainMap} (brighter on HDR displays: a jhgm box, like libjxl's), or how. - * Default: `true` (written if there is one) + * Whether to write the image's {@link Image.gainMap}, the extra data used to show brighter highlights on HDR + * displays. Default: `true` (written if there is one) */ gainMap?: boolean | GainMapEmbedOptions; } @@ -516,22 +497,23 @@ export interface JXLEncodeOptions extends EncodeOptions { export interface AVIFEncodeOptions extends EncodeOptions { /** 0-100, like avifenc's: higher is better and larger, 100 is lossless. Default: `60` */ quality?: number; - /** The quality of the alpha channel (if the image has transparency), like avifenc's. Default: `100` (lossless) */ + /** The quality of the transparency channel, if the image has transparency. Default: `100` (lossless) */ alphaQuality?: number; - /** Chroma subsampling: `'4:4:4'` keeps the full color resolution, `'4:2:0'` halves it (smaller). Default: `'4:4:4'` */ + /** + * How much color detail to keep: `'4:4:4'` keeps it all; `'4:2:0'` stores less color detail to make a smaller + * file. Default: `'4:4:4'` + */ subsampling?: '4:4:4' | '4:2:0'; /** How hard the encoder searches for the best modes: 0-2, faster to smaller. Default: `1` */ effort?: number; /** - * At most this many worker threads encode in parallel (the same file, faster: 2.9 times with 8 on a 1920x1080 - * photo at effort 1), `1` for none. Small images (under about 640x640 pixels at effort 0, 448x448 at 1, 320x320 - * at 2), and runtimes whose workers can't share memory (browsers without cross-origin isolation), encode without - * them. Default: the processors available, at most 8 + * Maximum worker threads for compression. Small images or runtimes that cannot share memory between workers use + * one thread. Default: the available processors, up to 8 */ threads?: number; /** - * Whether to write the image's {@link Image.gainMap} (brighter on HDR displays: a tone mapped item, like libavif - * writes), or how. Default: `true` (written if there is one) + * Whether to write the image's {@link Image.gainMap}, the extra data used to show brighter highlights on HDR + * displays. Default: `true` (written if there is one) */ gainMap?: boolean | GainMapEmbedOptions; } @@ -695,8 +677,9 @@ export class Image { } /** - * The image's gain map, if it has one (JPEGs from phones, Ultra HDR): how much brighter each part gets on HDR - * displays. {@link Image.encodeJPEG} writes it. `null` removes it + * Extra image data that lets high-dynamic-range (HDR) screens show brighter highlights. Standard screens show the + * image without the extra brightness. {@link Image.encodeJPEG}, {@link Image.encodeAVIF} and + * {@link Image.encodeJXL} can write it. `null` means there is no gain map */ get gainMap(): GainMap | null { return this.__gain_map__; @@ -712,8 +695,10 @@ export class Image { } /** - * The image's RGBA pixel data. It can be replaced by any 8-bit array with width * height * 4 bytes - * (it is used as is, without copying) + * The image's RGBA pixel data (red, green, blue and transparency, four bytes per pixel). It can be replaced by any + * 8-bit array with width * height * 4 bytes + * (it is used as is, without copying). Direct changes to this array or {@link Image.setPixelAt} don't update the + * image's gain map. */ get bitmap(): Uint8ClampedArray { return this.__bitmap__; diff --git a/src/render/svg.ts b/src/render/svg.ts index d003d377..ec11398d 100644 --- a/src/render/svg.ts +++ b/src/render/svg.ts @@ -4,7 +4,8 @@ *