diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 63d7e7b0..126c728a 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,8 +1,9 @@ name: coverage # measures the test coverage (npm run coverage) of every pushed branch and pull requests from forks, and how it -# changed: compared with the commit before the push on master, else with where the branch (or pull request) left -# master, measured in the same job. The job summary shows it; pushes to master get it as a commit comment, pushes to a +# changed, measured in the same job: compared with the commit before the push on master and on branches without a pull +# request (where the branch left master for a new branch or a force push), else with where the branch (or pull request) +# left master. The job summary shows it; pushes to master get it as a commit comment, pushes to a # branch with a pull request as a comment on it (one, updated by later pushes). Pull requests from forks have no # write access: only the job summary on: @@ -39,6 +40,8 @@ jobs: run: | if [ "$EVENT" = pull_request ]; then base=$(git merge-base "$PR_BASE" HEAD); name="the pull request's base" elif [ "$REF" = refs/heads/master ]; then base=$BEFORE; name="the previous commit on master" + elif [ -z "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number')" ] && git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then + base=$BEFORE; name="the previous push" else base=$(git merge-base origin/master HEAD); name="master where the branch left it"; fi if [ -z "$base" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null || [ "$base" = "$(git rev-parse HEAD)" ]; then echo "no base to compare with"; exit 0 @@ -55,6 +58,8 @@ jobs: REF: ${{ github.ref }} BEFORE: ${{ github.event.before }} PR_BASE: ${{ github.event.pull_request.base.sha }} + BRANCH: ${{ github.ref_name }} + GH_TOKEN: ${{ github.token }} - if: steps.base.outputs.summary run: node scripts/coverage-report.js "$SUMMARY" "$ROOT" env: diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index 9986ba3b..d30eada3 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -13,6 +13,10 @@ concurrency: group: node-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name != 'release' }} +# the jobs only install, build and test +permissions: + contents: read + jobs: build: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository @@ -94,7 +98,8 @@ jobs: node-version: 24.x - run: npm install - run: npm run build - - run: npm install --no-save esbuild@0.28.2 miniflare@4.20260730.0 edge-runtime@4.0.1 + # (esbuild's and workerd's postinstall scripts only check their binaries, which come with their platform packages) + - run: npm install --no-save --ignore-scripts esbuild@0.28.2 miniflare@4.20260730.0 edge-runtime@4.0.1 - run: node tests/edge/run.mjs browser: @@ -107,6 +112,8 @@ jobs: node-version: 24.x - run: npm install - run: npm run build - - run: npm install --no-save vite@6.4.5 playwright@1.64.1 esbuild@0.28.2 - - run: npx playwright install --with-deps chromium + - run: npm install --no-save --ignore-scripts vite@8.3.8 playwright@1.65.0 + # (the runner image has the libraries Chromium needs: --with-deps only added fonts through apt, which the tests + # don't use, and failed the job whenever the Ubuntu mirrors were unreachable) + - run: npx playwright install chromium - run: node tests/browser/run.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index f583bd5f..149b3b85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,158 @@ # Changelog +## 4.0.0 + +### Breaking changes + +Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation hints, then switch: the [migration guide](https://github.com/imagescript/ImageScript/blob/v4/guides/Migrating%20to%204.0.md) covers the 3.x-to-4.0 API changes and the sixteen 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({ fit: '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'` (`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. + +- **`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' })` 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()`. + +- **Iterating over an image, `iterateWithColors()` and `coordinates()` are removed**: `for (const [x, y] of image)`, `image.coordinates()` and `image.iterateWithColors()` are `image.pixels()`, which yields `[x, y, color]` for every pixel (`for (const [x, y] of image.pixels())`). + +- **`PNGMetadata` is removed**: use `Metadata`, which the encoders that write metadata take. The type is the same. + +- **The `max` argument of `cropCircle()` is removed**: `cropCircle(true, feathering)` and `cropCircle({ max: true })` are now `cropCircle({ fit: 'cover', feathering })`, and `cropCircle(false)` is `cropCircle()`, the default `fit: '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 })`. + +- **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()` 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`. + +- **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 `encodeJPEG({ 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, 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(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()` 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. + +- **`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. + +- **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. + +- **`resize()` needs a height in the positional form**: `image.resize(100)` throws a `TypeError`; `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 (`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. + +- **`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`. + +- **`rotate()` turns clockwise**, like CSS' `rotate()` (3.x turned counterclockwise): negate the angle for the old result. Its `fast` option is removed (the bilinear sampling is always the 8-bit one that `fast: true` was, with the same pixels) and `mode` (`'nearest-neighbor'`, `'bilinear'` or `'bicubic'`) picks the sampling like `transform()`'s. + +- **`dominantColor()` counts colors in a histogram, and its `threshold` is in channel values**: the colors are counted in 16 levels per channel (like sharp's `dominant`) and the average of the fullest bin is returned, instead of the average of the largest of 8 median cut clusters, so the result is a color the image has and not a mix of two hues; it can differ from 3.x's. The `threshold` is 0-256 instead of steps of 4 (0-64): the default `60` is 3.x's `15`; multiply a threshold you give by 4. + +- **`renderText()` takes the image's `width` and `height`**: `maxWidth` and `maxHeight` are `width` and `height`, which also set the image's size: lines wrap to the width (inside the outline), lines that don't fit the height are left out, and `horizontalAlign` and `verticalAlign` place the text in that box. Without them, the image is as large as the text, as before. `wrapHardBreaks` is removed: remove the line breaks from the text instead. An old `maxWidth` is ignored, so the text doesn't wrap: search for it. `0` no longer means no limit (it throws). + +- **The `fast` option of `resize()` and `scale()` is removed**, on `Image`, `Frame` and `Animation`: resizing always works like its default (`fast: true`); `{ fast: false }` is ignored. The precise path took 15-25% longer for opaque images, for results at most a step or two closer. + +- **`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()` (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()`. + +- **`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 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), the `fonts` option of `renderSVG()` is `font` (like `renderText()`'s), `GainMap.encode()` is `GainMap.encodeJPEG()` (named for the format it writes), `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()`, `saturation()` and `opacity()` are `color` and `factor`. + +- **Fewer types and classes**: `TextLayout` isn't exported, `renderText()` takes the layout options with its other options (`{ font, size, width: 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 (`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. + +- **Values that were wrapped, clamped or ignored throw**: colors have to be integers from -2^31 to 2^32 - 1, also the ones a `fill()` function returns (they wrapped to 32 bits; negative ones, which bitwise operators return, keep working); pixel coordinates are truncated without wrapping at 2^32; `compression` is an integer 0-9 like `effort` (it was clamped); frame durations that GIF, APNG or WebP can't store throw a `RangeError` like loop counts (they were cut; unchanged frames merged by the encoders are split at the limit); `frame.xOffset` and `frame.yOffset` are truncated to integers when set; `image.bitmap = view` throws a `TypeError` for a view that doesn't start at a multiple of 4 bytes (it was copied, so changes to the view were lost); `Colors.gradient()` throws for a `NaN` position with any number of stops; `encodeGIF()` of an animation without frames throws like the other encoders; the `quality` of `encodeJPEG()` is checked like the other formats' (0-100, it was clamped); `composite()` throws a `TypeError` for a source that isn't an `Image`. + +- **Numbers have to be numbers**: a numeric string (`opacity('0.5')`, `quality: '50'`, `frame.duration = '40'`, `maxPixels: '100'`), `null` or a boolean where a number goes throws a `TypeError` naming the value. 3.x converted them in some methods and rejected them in others. `NaN` and the infinities are still a `RangeError`. +- **Values that did nothing useful throw**: `roundCorners({ radius })` below 0 (it was clamped to 0) and `fisheye({ strength })` of 0 or less (which left an empty image). +- **Gradients blend transparency premultiplied**, like CSS: `Colors.gradient()`, `Image.linearGradient()` and `radialGradient()` between a transparent and an opaque color no longer pass through a dark midpoint (transparent to red is `ff00007f` in the middle, was `7f00007f`). Gradients between opaque colors are unchanged. +- **Images use a copy of the gain map they are given**: `image.gainMap = map` copies it, so images that were given one map no longer change each other's by resizing or cropping. + +- **Invalid choices throw a `TypeError` naming the valid ones**: the resize `mode` (an `Error`, thrown once resizing started, so `resize({ fit: 'cover' })` could move a frame's offsets before failing), `composite()`'s `mode`, `flip()`, `disposalMode` and `wrapStyle` (`RangeError`s; an empty or `null` `wrapStyle` was the default). + +- **No creation time and no version in encoded files by default**: the encoders only write a creation time when `metadata.creationTime` is given, so the same image encodes to the same bytes, and the default software is `'ImageScript'` without the version. `creationTime: null` and `software: null` leave the field out. + +- **Drawing an SVG image that can't be drawn throws**: an `href` that isn't a data URI or one of the `images`, an image that can't be decoded (with the decoding error as its `cause`), an unsupported data URI type or an embedded SVG that can't be read throw once they are drawn (images in `` or hidden ones don't); they drew nothing before. + +- **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 + +- `GainMap.decode(data, { maxPixels })` takes the limit of `Image.decode()`, and the `LimitError` that these limits throw is exported (with `name` `'LimitError'`). +- `rotate(angle, { expand: false })` keeps the image's size for quarter turns, too: the turned image is centered and what doesn't fit is cut off (they swapped width and height). +- `renderSVG()` throws for masks, clip paths, patterns, markers and filter images nested in each other so deeply that they would multiply (three per level took minutes at ten levels), and stops at 24 `feTurbulence` octaves, which are all that its output can hold. Shapes far outside the image no longer make rendering hang. +- `renderSVG({ maxLayerPixels })` limits the layers SVG rendering uses at once (groups with opacity, masks, filters, ...; 16 bytes per pixel each), so nested ones can't multiply the memory of a small SVG: default `4 * maxPixels`. `renderSVG({ maxImagePixels })` limits the raster images an SVG references together (default `maxPixels`; each was limited on its own before). +- `renderText({ maxPixels })`, default 16383², counting the outline, checked before anything is allocated. +- `Animation.clone()`, `Animation.crop()` and `Animation.flip()` (each frame mirrored, and its place on the canvas), and the `FlipDirection` type. +- `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()` 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. +- `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` and `DitherOptions` types that `quantize()` and the GIF encoders share. The option types of the animation encoders extend those of the format's still images (`APNGEncodeOptions` extends `PNGEncodeOptions`, `WebPAnimationEncodeOptions` extends `WebPEncodeOptions` without `threads`). `QuantizeOptions` is a union: TypeScript reports a call with both `colors` and `palette` (it throws at runtime). +- `RenderSVGOptions` is a union of the sizes (`width`, `height` or `scale`, TypeScript reports a call with more than one, which throws at runtime) and `SVGResourceOptions`, the limits, fonts and images. +- `renderSVG({ font })` takes a single font without an array, like `renderText()`. + +### Fixes + +- `image.composite(image, { x, y })` onto itself (or an image sharing its pixels) read pixels it had already overwritten, and differed from compositing a copy. +- `dominantColor({ threshold })` outside 0-256, `GainMap.encodeJPEG({ quality: 0 })` and a `metadata` that is not an object throw instead of being clamped or ignored. +- `Animation.resize()` changes a frame once when the animation lists the same `Frame` (or two frames share one `Image`) more than once, as a hold or a ping-pong does; each listing resized or moved it again before (`new Animation([f, f, f]).resize(50, 50)` made 13x13 frames). +- The animation encoders (`Animation.webpEncoder()`, `gifEncoder()` and `apngEncoder()`) cut frames to the canvas like `Animation.encode*()` does when `optimize` is `false`: a frame reaching over the edge lost the part it covered (GIF, WebP) or threw (APNG). +- `AnimationEncoder.add()` rejects its promise for invalid frames instead of throwing synchronously, so `encoder.add(frame).catch()` sees them. +- `Image.linearGradient()` and `radialGradient()` work with fractional sizes (`width / 2`, ...) at the truncated size of the image; they threw a `RangeError` because they wrote past the pixels. +- `scale()` keeps each side at least one pixel like `resize()`, where `new Image(7, 1).scale(0.5)` threw. +- A damaged JPEG XL whose blend refers to a channel that doesn't exist throws the decoding error instead of a `TypeError` from inside the decoder. +- The gain map's checks across its values (`min` and `max`, `hdrCapacityMin` and `hdrCapacityMax`) name the values that conflict, since they apply when the map is cloned or encoded, not when a value is set. +- `Animation.resize()` resizes frames that share one `Image` at different offsets to the size their edges give (they got the first one's width, so a frame could reach past the canvas), and throws for a width or height below one pixel instead of returning 1x1. +- The GIF encoders throw a `RangeError` for a canvas wider or higher than 65535 pixels (it was stored modulo 65536: 70000 became 4464), and all encoders for an animation without a canvas (every frame outside it), which WebP wrote as 16777216x16777216. +- `Animation.decode()` of a JPEG XL animation counts the pixels of all frames against `maxPixels`, like the other formats; a frame was counted alone. +- `resize({ fit: 'cover' })` and `fill()` leave an image (and its gain map) as it was when they throw: a `cover` that couldn't allocate its result had already changed the gain map, and a `fill()` function that threw halfway had written half the pixels. +- Animations that are cut off keep the frames that arrived whole and nothing else, and throw without a whole one: a GIF (it decoded a blank image when cut before its first frame, and returned the half-decoded last frame otherwise), an APNG (it showed the first frame only), a lossy WebP animation and a lossy JPEG XL animation (both returned a damaged last frame, the JPEG XL one also for frames of a single section). +- Numeric arguments that accepted values silently refuse them like the rest: `scale('2')`, a `null` option value (`composite({ opacity: null })`, `roundCorners({ radius: null })`, `fisheye({ strength: null })`, a frame's `duration` and offsets in the encoders' `add()`), `blur('2')` and the gain map's setters with a non-number are a `TypeError`. `cropCircle({ feathering })` outside 0-1 throws instead of being clamped, and `renderText({ size: 0 })` throws like a negative size (it returned a 1x1 image). +- Damaged files with a frame header cut short (JPEG), a BitsPerSample tag outside of the file (TIFF) or sizes that don't match their pixels (GIF, TGA, TIFF) are decoding errors, where they read missing bytes (`JPEG: undefined-bit precision`) or threw a `RangeError` from making the image. +- `Colors.gradient()` with one color throws for a `NaN` position like the others. +- TIFF files that ask for far more memory than they are are refused before anything is allocated: YCbCr subsampling factors other than 1, 2 and 4 (65534x65535 took 4 GB and 5 s for a 32 KB file) and more than 32 samples per pixel (1 GB for 33 KB), and the memory of the YCbCr units counts against `maxPixels`. +- `Animation.decode()` of an APNG or WebP composites each frame as it is decoded, so its peak memory is 1.3 times the result instead of 2.2 (all decoded frames were held next to the composited ones); GIFs let go of each frame's color indices once it is composited (1.45 times, now 1.2). +- `renderSVG()` bounds the work of strokes with round joins: a join was a whole circle of up to 256 points at every vertex of a flattened curve, so a 17 KB path took 9 s and 1 GB and a 50 KB one 2.7 GB (now a wedge on the outer side, 1.6 s and 220 MB, and 0.3 s and 350 MB). Round joins differ from the circles by at most 5 of 255 on the edge pixels. Shapes whose edges cross the rows of the image more than 2^27 times (thousands of huge overlapping strokes took 38 s from 51 KB of SVG) throw a `LimitError`. +- Bundlers drop the parts of the library a page doesn't use: the build's `dist/package.json` declared only `"type": "module"`, which hid the `"sideEffects": false` of the package from them, so `import { Colors }` bundled the whole library (201 kB minified, now 3.4 kB). +- `renderSVG()` rasterizes shapes with many edges in near linear time: the edges that start in a row were inserted one by one into the sorted active ones, so text of 20000 glyphs took 15 s (now 0.4 s) and 8000 random curves 18 s (now 2.3 s). +- `renderSVG()` keeps the memory of dashed strokes bounded: dashes beside the image are left out and the others are stroked a batch at a time, so a line of a million dashes (157 bytes of SVG) no longer takes 650 MB. The rasterizer also keeps one sub-scanline's crossings at a time instead of 16. +- `renderSVG()` parses `transform` lists in linear time: a long list of unclosed functions took 4.8 s at 280 KB. + ## 3.20.3 ### Deprecated @@ -57,7 +210,7 @@ - `resize({ width })` and `resize({ height })` on images, frames and animations: the missing side keeps the aspect ratio, `resize({ width: 256, mode: 'bicubic' })`. - `image.coordinates()` yields `[x, y]` and `image.pixels()` yields `[x, y, color]` for every pixel, row by row. -- `cropCircle({ size: 'contain' | 'cover' })`: `'contain'` (the default, as before) keeps the circle inside the image. +- `cropCircle({ fit: 'contain' | 'cover' })`: `'contain'` (the default, as before) keeps the circle inside the image. - `Image.renderText(text, { font, size, color, layout, ...style })`: the text first, everything else in the options. `layout` is `TextLayoutOptions` or a `TextLayout`: `Image.renderText('Hi', { font, size: 32, layout: { maxWidth: 400 } })`. - `Infinity` as the loop count of animations that loop forever (`new Animation(frames, { loopCount: Infinity })`, @@ -81,7 +234,7 @@ `APNGAnimationEncoderOptions`: they are the same as `AnimationEncodeOptions`, `WebPAnimationEncodeOptions`, `GIFEncodeOptions` and `APNGEncodeOptions` with `loopCount`, which are all the types there are in 4.0. - `encode()`: use `encodePNG()`, which takes the same arguments. Removed in 4.0. -- `cropCircle(max, feathering)` and the `max` option: use `cropCircle({ size: 'contain' | 'cover' })`. Removed in 4.0. +- `cropCircle(max, feathering)` and the `max` option: use `cropCircle({ fit: 'contain' | 'cover' })`. Removed in 4.0. ### Documentation diff --git a/LICENSE b/LICENSE index 3c1eb49a..19249d8c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,15 +1,19 @@ -This software is licensed under the following license(s): -- GNU AFFERO GENERAL PUBLIC LICENSE, Version 3 -- MIT License +Copyright (c) 2020-2026 Mathis Mensing -== SPDX-License-Identifier: AGPL-3.0-or-later OR MIT == +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -You may choose to comply with either one of the above -mentioned licenses, but a license must be chosen. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -The corresponding license files can be found in the projects -root directory, prefixed with LICENSE, suffixed with their -corresponding SPDX identifier. - -Parts of this software are derived from third party projects under their -own licenses (MIT, Apache-2.0 and the IJG license), see THIRD_PARTY_NOTICES.md. \ No newline at end of file +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/LICENSE.AGPL-3.0 b/LICENSE.AGPL-3.0 deleted file mode 100644 index ce0100f6..00000000 --- a/LICENSE.AGPL-3.0 +++ /dev/null @@ -1,619 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS diff --git a/LICENSE.MIT b/LICENSE.MIT deleted file mode 100644 index 8f0286a8..00000000 --- a/LICENSE.MIT +++ /dev/null @@ -1,19 +0,0 @@ -Copyright (c) 2023 Mathis Mensing - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 15768387..c826144f 100644 --- a/README.md +++ b/README.md @@ -13,21 +13,24 @@ --- -**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 (TypeScript 5.7 or newer: with older versions and `skipLibCheck`, the encoders' return types become `any`). 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 deno add jsr:@matmen/imagescript # Deno ``` +Upgrading from 3.x? Read [Migrating to 4.0](https://github.com/imagescript/ImageScript/blob/v4/guides/Migrating%20to%204.0.md) first. + Without a bundler, import it from jsDelivr in the browser: ```html ``` +The codecs that can use worker threads (large AVIF and JPEG XL images, lossless WebP; the `threads` option) do so in +Node.js, Deno and Bun, and in browsers on cross-origin isolated pages. Where workers can't start, the work runs on the +calling thread: the output is the same, only slower, and nothing warns about it. Browsers refuse workers from another +origin, so the jsDelivr example above runs single-threaded; serve the package from your own origin (on a cross-origin +isolated page) to get threads. + +Bundlers that write one file (esbuild, webpack) don't copy the worker's module: serve `dist/util/worker.js` next to the +bundle, or the same single-threaded fallback is used. Vite and Rollup emit it. + --- ### Example @@ -45,7 +57,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()); ``` @@ -71,17 +83,26 @@ await writeFile('output.png', await image.encodePNG()); | GIF, APNG (animations) | ✓ | ✓ | | BMP, QOI, TGA, Netpbm | ✓ | ✓ | | TIFF, ICO/CUR, Radiance HDR | ✓ | | -| [HDR gain maps](https://imagescript.dev/classes/GainMap.html) (Ultra HDR and iPhone JPEGs, AVIF, JPEG XL) | ✓ | ✓, kept through edits | +| [HDR gain maps](https://imagescript.dev/classes/GainMap.html) (brighter highlights on HDR displays; JPEG, AVIF, JPEG XL) | ✓ | ✓ | [Animations](https://imagescript.dev/classes/Animation.html) can be decoded from GIF, APNG, WebP and JPEG XL, and encoded as GIF, APNG or WebP, all at once or -[frame by frame](https://imagescript.dev/classes/Animation.html#webpencoder). Embedded color profiles (Display -P3, Adobe RGB, ...) are converted to sRGB when decoding, so images look the way browsers show them. +[frame by frame](https://imagescript.dev/classes/Animation.html#webpencoder). Lossless encoding keeps every pixel +exactly; lossy encoding makes smaller files by approximating some image detail. + +Not supported (decoding them throws an error): CCITT-compressed TIFF and arithmetic-coded JPEG. Lossless JPEG XL is +fast but not small: for small lossless files, PNG or WebP are the better choice. + +Embedded color profiles are converted to sRGB, the standard color format for most screens, where supported. CMYK and +lookup-table profiles are left unchanged. + +Supported image operations preserve gain maps; editing pixels directly with `image.bitmap` or `setPixelAt()` does not. -- [Text rendering](https://imagescript.dev/classes/Image.html#rendertext) with TrueType, OpenType and WOFF fonts: - all scripts, color emoji, variable fonts +- [Text rendering](https://imagescript.dev/classes/Image.html#rendertext) with TrueType, OpenType and WOFF fonts + (not WOFF2; some OpenType outline formats are unsupported): right-to-left text and scripts where letters connect or + combine, plus color emoji and variable TrueType fonts - [SVG rendering](https://imagescript.dev/classes/Image.html#rendersvg), including text (with fonts you pass - or fonts embedded in the SVG) + or fonts embedded in the SVG); no automatic fetching of external images or fonts - [Resizing](https://imagescript.dev/classes/Image.html#resize), [cropping](https://imagescript.dev/classes/Image.html#crop), [rotating](https://imagescript.dev/classes/Image.html#rotate), diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 657088dd..e9cfabf0 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # Third party notices -ImageScript is licensed under AGPL-3.0-or-later OR MIT (see [LICENSE](LICENSE)). Parts of it are derived from the +ImageScript is licensed under MIT (see [LICENSE](LICENSE)). Parts of it are derived from the following projects, under their own licenses. Each derived file says so in its header. | Project | Used in | License | diff --git a/bench/README.md b/bench/README.md index 1afb91f1..0402d23b 100644 --- a/bench/README.md +++ b/bench/README.md @@ -1,9 +1,8 @@ # ImageScript benchmark -How [ImageScript](https://github.com/imagescript/ImageScript) compares to other image libraries for Node.js: speed, file -sizes and what installing each takes. ImageScript is plain JavaScript with no dependencies, so it runs wherever -JavaScript does, like Jimp, image-js and PureJsImage; sharp and @napi-rs/canvas wrap native libraries (libvips, Skia), -Photon is WebAssembly, and so is jsquash (Squoosh's libavif and libjxl, for AVIF and JPEG XL). +This benchmark compares processing speed, output-file size and install size. Lower times and smaller files are better, +but compare lossy files alongside their quality scores. Results depend on the machine, so compare libraries within a +single run. The Method section explains the test inputs and measurements. 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-12 with Node.js v26.13.1 o | render SVG | **106 ms** | – | – | – | – | 25 ms | – | 6.8 ms | | thumbnail, keeping HDR | **115 ms** | – | – | – | – | 94 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/bench/package-lock.json b/bench/package-lock.json index 485125fd..46bf5228 100644 --- a/bench/package-lock.json +++ b/bench/package-lock.json @@ -8,14 +8,14 @@ "dependencies": { "@jsquash/avif": "2.1.1", "@jsquash/jxl": "1.3.0", - "@napi-rs/canvas": "1.0.11", + "@napi-rs/canvas": "1.1.0", "@silvia-odwyer/photon-node": "0.3.4", "image-js": "1.7.1", "imagescript": "3.20.3", "jimp": "1.6.1", "purejsimage": "0.17.0", "sharp": "0.35.5", - "tinybench": "6.2.2" + "tinybench": "6.2.3" } }, "node_modules/@borewit/text-codec": { @@ -987,9 +987,9 @@ } }, "node_modules/@napi-rs/canvas": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.11.tgz", - "integrity": "sha512-vt5tog1cr+QKadIddGe+3LrcX0EmjEd0izF717Uwf92mBmgYfq2XRmL2r4bZU40spB69exxhrBn9I91HYEr/7w==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.1.0.tgz", + "integrity": "sha512-k+ph/D8H9GS8ZZ48okvHrKeJynkqAA1kMUZRuzRWNlQUDHXngq7oRaWMZBdmSfd9ZhVy9bwNKei/7wMboI/uMg==", "license": "MIT", "workspaces": [ "e2e/*" @@ -1002,23 +1002,23 @@ "url": "https://github.com/sponsors/Brooooooklyn" }, "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "1.0.11", - "@napi-rs/canvas-darwin-arm64": "1.0.11", - "@napi-rs/canvas-darwin-x64": "1.0.11", - "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.11", - "@napi-rs/canvas-linux-arm64-gnu": "1.0.11", - "@napi-rs/canvas-linux-arm64-musl": "1.0.11", - "@napi-rs/canvas-linux-riscv64-gnu": "1.0.11", - "@napi-rs/canvas-linux-x64-gnu": "1.0.11", - "@napi-rs/canvas-linux-x64-musl": "1.0.11", - "@napi-rs/canvas-win32-arm64-msvc": "1.0.11", - "@napi-rs/canvas-win32-x64-msvc": "1.0.11" + "@napi-rs/canvas-android-arm64": "1.1.0", + "@napi-rs/canvas-darwin-arm64": "1.1.0", + "@napi-rs/canvas-darwin-x64": "1.1.0", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.1.0", + "@napi-rs/canvas-linux-arm64-gnu": "1.1.0", + "@napi-rs/canvas-linux-arm64-musl": "1.1.0", + "@napi-rs/canvas-linux-riscv64-gnu": "1.1.0", + "@napi-rs/canvas-linux-x64-gnu": "1.1.0", + "@napi-rs/canvas-linux-x64-musl": "1.1.0", + "@napi-rs/canvas-win32-arm64-msvc": "1.1.0", + "@napi-rs/canvas-win32-x64-msvc": "1.1.0" } }, "node_modules/@napi-rs/canvas-android-arm64": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.11.tgz", - "integrity": "sha512-dtJS4FQEfH9OwSYLtMrEnj7T8DzBbJc/97LI/OkiWOYpW/iW5t6R/gcxVrUXS8l45gCVpwMufgucRe/pe0ISqA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.1.0.tgz", + "integrity": "sha512-mKrSmRyhWK4q699Q1xthbZIZi7JBvLtsRNnOZAeFaLPOFT1yo8y82aRC2nNZbXTGJXU7fVDONgGQPr0vo3kerw==", "cpu": [ "arm64" ], @@ -1036,9 +1036,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.11.tgz", - "integrity": "sha512-J/JylWoJTG8OQqZaiqly4IODyAxNofUlZo0wtSBJtHk6AdymbdIkPynAM1JvQaZpPaG5h+msk9QuK8y4i1ooqw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-iqwST4g0oH+x8VCpBIYh+MYokv/mVvkM+v9oJGB0d9A3P2qBh42ogJlVyWq9SpPCrAjS7tplMb1D3OIfxl+utw==", "cpu": [ "arm64" ], @@ -1056,9 +1056,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.11.tgz", - "integrity": "sha512-nlecCk3xy06eAGC7krZtzNDm9UNTHKuHQYK8ZYegdjzgSL8anGt/eLJZm86OWaNgZkwQwvDqfSmG8Nqe6MPZNQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.1.0.tgz", + "integrity": "sha512-jkeA8eVTxDum03LTvc32YJ28F6KB1U0gYwnWMfOAUQZvWWEh/kvONby4iWGJx3+Tw45UqMGMXjtw2GKNVZDhVg==", "cpu": [ "x64" ], @@ -1076,9 +1076,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.11.tgz", - "integrity": "sha512-DKImmG+sTPolE0kGPsYPBxTtOjb3INALp32O2lW2CfSr0bAzSEKKuu+GrQbtilClyoPOahUErS3OrznFDraoWA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.1.0.tgz", + "integrity": "sha512-Y4YkKG1ijoqkgUdTEEBNG4k+2BK5bR5jGL6S56YxCXYPwGfUd5nED8/74xyc7z+bpixPasCjSYoP3xYPOIGNxw==", "cpu": [ "arm" ], @@ -1096,9 +1096,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.11.tgz", - "integrity": "sha512-55sykACGENj5j1rhj+81AO8JbR9HYgGjRZ7/H+pNr4VE1DvINKYNMTcutk8ZmWxk276jAQvUKJ3+Kdj9GXU3lQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.1.0.tgz", + "integrity": "sha512-dE/XthqYkiWcBVtKFlZbUXwAYfDuQGqDWixznipJNmevLGpprNnTVQZyftm1RZJudkIOn2EkzYZDMMETM08Row==", "cpu": [ "arm64" ], @@ -1116,9 +1116,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.11.tgz", - "integrity": "sha512-6Jz6jys17yXydO+WdJgu7JhoB4K7/w9sWHByzzTmLjQghUx3TDMWvLv+UcCdnWhg4oprET67EMymnrETdeGNAg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.1.0.tgz", + "integrity": "sha512-DqZ/CMcHlhU56pPjv9AP0h3U7MFQ5LzC1HzMyRMotL+PFsJmZJA/ziApPYVyyz78DaVLhI8t3So3ztqtijbYpg==", "cpu": [ "arm64" ], @@ -1136,9 +1136,9 @@ } }, "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.11.tgz", - "integrity": "sha512-lf2XeEebWiUYVOVEjLXxF7dfXrBBSEOBn9AqBh39VYXrPt5cDqI+1t58IFSjn8npqkg5jIOs8TQbjKABbFW1gQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.1.0.tgz", + "integrity": "sha512-kJ7m5n2VffxWXM5QGa5lfq8ZN4LdOJEoz3aAJAmIqYvKiZw1gRdXqpucnRXsF6D9nLYiiIlevaAtAclR7c5ilg==", "cpu": [ "riscv64" ], @@ -1156,9 +1156,9 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.11.tgz", - "integrity": "sha512-iDapw7nFSE5tsmQxcH2+KotJhyfVPmHd6vUOQN/Em+8WNbkZJwJ/rDowlJMFnMl0SYSDSfgKCNUGi6Sn5++Smg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.1.0.tgz", + "integrity": "sha512-uQ9JuJlRhQdHyttcMWOiwLkfIqkqaB0YFUVe835LUe12THt2b51HXkTItq0fxWT1jdhUPX2ylWZ1walkJyD6GQ==", "cpu": [ "x64" ], @@ -1176,9 +1176,9 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.11.tgz", - "integrity": "sha512-26q26AyITo3CGZX4HWFIMgBMA1lu3YN07gxzBm4tW6Hglq/OVyTP50kDrmWuKybCX6dh3m8Ly+c7YhI+sqCgCQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.1.0.tgz", + "integrity": "sha512-has4CPPAkkCac/LZ13VAw5p+MJLdaKVoyrhumN/A7uJd6960xYkJNDZgUebyxiha82AFzXx6RV+QJg5qGG079g==", "cpu": [ "x64" ], @@ -1196,9 +1196,9 @@ } }, "node_modules/@napi-rs/canvas-win32-arm64-msvc": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.11.tgz", - "integrity": "sha512-cUO96IdsfvIj2S+2PNcDSkTcLhjPj8pIOEWRV1Kj70MOP4wnE9zSk7N4C1EKHlFGRcgdR5AkbnBZj0dfHK9nbQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.1.0.tgz", + "integrity": "sha512-MJnktLKNijmXqdjTughHDZ9TDLZ9uUm2hvFXqaOqn0+w7lphXFnft6bpSgQAc9U1exc20UGIBlzGu9+kiCj9Ag==", "cpu": [ "arm64" ], @@ -1216,9 +1216,9 @@ } }, "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.11.tgz", - "integrity": "sha512-DFYp/ZQRdKunI/LPc0UkRXG1vlNjJKOJf8O8Ctb8+wm2Nt2ELg9Eamc31D2VoakkfGo8L+z0Iejz6WtFbQwyGw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.1.0.tgz", + "integrity": "sha512-08575bFk7iiadxap4h+JxgGut9xDCHAGum6wh0eInN53zEMImkmEHi5MushX2FOmNO7qRNX7BPiDC5ZYBwHeEg==", "cpu": [ "x64" ], @@ -2041,9 +2041,9 @@ } }, "node_modules/tinybench": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", - "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.3.tgz", + "integrity": "sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==", "license": "MIT", "engines": { "node": ">=20.0.0" diff --git a/bench/package.json b/bench/package.json index 7125dfaa..8df86364 100644 --- a/bench/package.json +++ b/bench/package.json @@ -9,13 +9,13 @@ "dependencies": { "@jsquash/avif": "2.1.1", "@jsquash/jxl": "1.3.0", - "@napi-rs/canvas": "1.0.11", + "@napi-rs/canvas": "1.1.0", "@silvia-odwyer/photon-node": "0.3.4", "image-js": "1.7.1", "imagescript": "3.20.3", "jimp": "1.6.1", "purejsimage": "0.17.0", "sharp": "0.35.5", - "tinybench": "6.2.2" + "tinybench": "6.2.3" } } diff --git a/eslint.config.mjs b/eslint.config.mjs index 124d0081..9ae50059 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -22,6 +22,13 @@ export default tseslint.config( // ({ removed: _, ...rest }) leaves out a property '@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }], + // option checks live in util/options.ts, so one option is checked (and worded) the same everywhere: use a + // helper there, or add one (a check that only one place needs can say why with an eslint-disable) + 'no-restricted-syntax': ['error', { + selector: "ThrowStatement > NewExpression[callee.name=/^(Range|Type)Error$/] > :matches(TemplateLiteral, Literal)[quasis.0.value.raw=/must be/], ThrowStatement > NewExpression[callee.name=/^(Range|Type)Error$/] > Literal[value=/must be/]", + message: "Check options with the helpers of util/options.ts (oneOf, finite, pixelLimit, ...) instead of a hand-written 'must be' error." + }], + // SonarJS: code that does nothing or something else than it reads like 'sonarjs/array-callback-without-return': 'error', 'sonarjs/no-all-duplicated-branches': 'error', @@ -41,5 +48,16 @@ export default tseslint.config( // regular expressions that backtrack superlinearly (ReDoS) 'sonarjs/slow-regex': 'error' } + }, + { + // the dimension limits of a format aren't options (quantize.ts holds the dithering options, so it stays checked) + files: ['src/codecs/**/*.ts'], + ignores: ['src/codecs/quantize.ts'], + rules: { 'no-restricted-syntax': 'off' } + }, + { + // (where the helpers are) + files: ['src/util/options.ts'], + rules: { 'no-restricted-syntax': 'off' } } ); diff --git a/guides/Migrating to 4.0.md b/guides/Migrating to 4.0.md new file mode 100644 index 00000000..e4e86217 --- /dev/null +++ b/guides/Migrating to 4.0.md @@ -0,0 +1,651 @@ +# Migrating to 4.0 + +ImageScript 4.0 removes everything 3.x deprecated, reshapes parts of the API, and changes sixteen behaviors. Most of the +removals are mechanical: the compiler points at them. The sixteen 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`). + +The recommended path: + +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. +3. Switch to 4.0, fix what the compiler reports with the [renamed and reshaped APIs](#renamed-and-reshaped-apis), and go + through the sixteen behavior changes. The [checklist](#checklist) at the end has grep patterns + for finding the code they affect. + +## Behavior changes + +These change what code does without a compile error. + +### `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 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() +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(); +} +``` + +`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 +``` + +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` + +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 = [...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. `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`. + +`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. + +### 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` (a GIF without a loop extension, which plays once, reports `0`). + +```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()`. + +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. This limit is enforced since 3.x release 3.20.2; earlier releases silently wrapped a GIF count of `65536` to `0`, which means forever. `Infinity` is always +fine. + +Search your code for: `loopCount: -1`, `loopCount = -1`, `loopCount === -1` and `loopCount < 0`. + +### Resize fit modes + +`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'`. `ResizeFit` is the +type of the `fit` option. + +| 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.resize({ width: 800, height: 600, fit: 'contain' })` | +| `image.cover(800, 600)` | `image.resize({ width: 800, height: 600, fit: 'cover' })` | + +### 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 | + +### `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 })` | +| `image.rotate(90, false)` (swapped width and height anyway) | `image.rotate(-90)`: with `expand: false` quarter turns keep the size now, like every other angle, and cut off what doesn't fit | + +Search your code for: `.rotate(`. + +### JPEG composites transparent pixels onto black + +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()` 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 | +| --- | --- | +| `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()`, `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.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 +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. + +Search your code for: `.crop(`, `.resize(` with `fit: 'cover'`, and `.rotate(` on frames. + +### Values that were wrapped, clamped or ignored now throw + +Several values that 3.x quietly changed into something else now throw, naming the value: + +| Value | 3.x | 4.0 | +| --- | --- | --- | +| Colors (`setPixelAt()`, `fill()`, options like `background`, `Colors.toRGBA()`, ...) | any number, wrapped to 32 bits (`2 ** 32 + 5` was `0x00000005`, `1.5` was `1`) | an integer from `-2 ** 31` to `2 ** 32 - 1`, else a `RangeError`. Negative colors keep working: they're what bitwise operators return (`r << 24 \| g << 16 \| b << 8 \| a`) and read as the same unsigned color | +| Colors returned by a `fill()` function | anything, `undefined` was transparent black | checked like any color | +| Pixel coordinates (`getPixelAt()`, `setPixelAt()`, `crop()`, `extract()`, `composite()`'s `x` and `y`) | truncated to 32 bits, so `2 ** 32` was `0` | truncated (fractions dropped) without wrapping: out of the image throws as before | +| `compression` of `encodePNG()`, `encodeAPNG()` and `apngEncoder()` | clamped to 0-9 and truncated | an integer from 0 to 9, like `effort`, else a `RangeError` | +| Frame durations over what the format stores (GIF and APNG 655350 ms, WebP 16777215 ms) | cut to the format's limit | a `RangeError`, like loop counts that don't fit. Unchanged frames that are merged are split at the limit instead | +| `frame.xOffset`, `frame.yOffset` (and encoder `add()` offsets) | stored as given, fractions included (`0.4`) | truncated to integers when set, like image sizes, so the offset you read is the one that is encoded | +| `image.bitmap = view` with a view that doesn't start at a multiple of 4 bytes | copied, so changes to the view no longer reached the image | a `TypeError`: pass an aligned view, or `view.slice()` for a copy | +| A number given as a string (`'0.5'`), `null` or a boolean where a number goes (`opacity('0.5')`, `quality: '50'`, `frame.duration = '40'`, `maxPixels: '100'`, `Colors.gradient()`'s positions) | converted by some methods (`'0.5'` was `0.5`, `null` was `0`), rejected by others | a `TypeError` naming the value, by all of them | +| `roundCorners({ radius })` below 0 | clamped to 0 | a `RangeError` | +| `fisheye({ strength })` of 0 or less | an empty image | a `RangeError` | +| `Colors.gradient()` at a `NaN` position | transparent black with two stops, a `RangeError` with more | a `RangeError` with any number of stops | +| `encodeGIF()` of an animation without frames | a GIF that doesn't decode | an `Error`, like `encodeAPNG()` and `encodeWebP()` | +| `quality` of `encodeJPEG()` | clamped to 1-100 | a number from 0 to 100 (0 is 1), else a `RangeError`, like the other lossy formats | +| The source of `composite()` when it isn't an `Image` | a `TypeError` from inside (`Cannot read properties of undefined`) or nothing | a `TypeError` naming the call | + +A value outside a fixed set of choices is a `TypeError` naming them all, like `resize mode must be 'nearest-neighbor', +'bilinear', 'bicubic' or 'area', not 'lanczos'`. This changes the error type of the resize `mode` (an `Error`, and only +thrown once resizing started), `composite()`'s `mode`, `flip()` and `disposalMode` (`RangeError`s), and `wrapStyle` +(a `RangeError`; `''` and `null` were the default, now they throw). + +Search your code for: `compression:`, `xOffset`, `yOffset`, `.bitmap =`, `duration:` above 655 seconds, and catch +blocks that check for `RangeError` around `resize()`, `composite()`, `flip()` and `disposalMode`. + +### Gradients blend transparency premultiplied + +`Colors.gradient()`, and so `Image.linearGradient()` and `radialGradient()`, blend colors with an alpha below 255 +premultiplied, like CSS does. 3.x blended the channels as they are, so a gradient from transparent black to red went +through a dark red (`7f00007f`) and one from red to transparent blue through purple; now the middle of the first is +`ff00007f` and what fades out leaves no color behind. Gradients between opaque colors are unchanged. + +### Images use a copy of the gain map they are given + +`image.gainMap = map` stores a copy. 3.x kept the object, so two images given one map changed it for both when either +was resized or cropped. Now every image has its own, and changes to `map` after assigning it don't reach the image. +`image.gainMap` returns the image's own map, which the image's methods keep up to date. + +### `dominantColor()` counts colors in a histogram, thresholds are channel values + +3.x grouped the colors into 8 clusters (median cut) and returned the average of the largest one, which can lie between +two hues when a cluster spans both. 4.0 counts the colors in 16 levels per channel (4096 bins, like sharp's `dominant`) +and returns the average of the pixels in the fullest bin: a color the image has. The result can differ from 3.x's for +images with many similar colors, and is the same for flat colors. + +The `threshold` is in channel values (0-256): colors whose channels are all below it count as black, all above 255 +minus it as white. In 3.x it counted in steps of 4 (0-64), so the default `15` meant 60. The default is the same; a +threshold you give has to be multiplied by 4: + +| 3.x | 4.0 | +| --- | --- | +| `image.dominantColor(true, true, 15)`, `dominantColor({ threshold: 15 })` | `image.dominantColor({ threshold: 60 })` | + +Search your code for: `dominantColor(`, and compare the colors it returns. + +### `renderText()` takes the image's size + +`maxWidth` and `maxHeight` are `width` and `height`, and they are the size of the image: the lines wrap to the width +(inside the outline of `strokeWidth`), the lines that don't fit the height are left out, and `horizontalAlign` and +`verticalAlign` place the text in that box. In 3.x the image was only as wide as the widest line and as high as the +lines, so the alignment only moved lines against each other. Without a `width` or `height` the image is as large as +the text, as before. `wrapHardBreaks` is removed: + +| 3.x | 4.0 | +| --- | --- | +| `{ font, size, maxWidth: 300 }` (wrapped, as wide as the widest line) | `{ font, size, width: 300 }` (wrapped, 300 pixels wide); `trim()` the image for the old size | +| `{ font, size, maxHeight: 100, verticalAlign: 'bottom' }` | `{ font, size, height: 100, verticalAlign: 'bottom' }`: the last lines that fit, at the bottom of a 100 pixel high image | +| `{ font, size, maxWidth: 0 }` (no limit) | `{ font, size }`: `0` throws | +| `{ font, size, wrapHardBreaks: false }` | `Image.renderText(text.replace(/\r?\n/g, ''), { font, size })` | + +An old `maxWidth` or `maxHeight` is ignored, so the text isn't wrapped or cut off. Search your code for: `maxWidth`, +`maxHeight` and `wrapHardBreaks`. + +### Encoded files have no creation time and no version by default + +In 3.x, every encoder wrote the time of encoding as the creation time and `ImageScript vX.Y.Z` as the software, so the +same image never encoded to the same bytes twice, and every file told which release made it. In 4.0, the creation time +is only written when `metadata.creationTime` is given, and the default software is `'ImageScript'`, without a version +(`software: null` leaves it out): + +| 3.x | 4.0 | +| --- | --- | +| `image.encodePNG()` (with the time of encoding) | `image.encodePNG({ metadata: { creationTime: Date.now() } })` | + +Search your code for: code that reads `Creation Time`, Exif `DateTime` or the software version from encoded files. + +### SVG and text rendering limits, and images that can't be drawn + +`Image.renderSVG()` used to draw nothing for an `` it couldn't use. In 4.0, drawing one throws, so a rendered SVG +is the image it describes or nothing: + +- an `href` that isn't a data URI or a key of the `images` option (such as `href="logo.png"` or an `https:` URL, which + are never fetched): pass the image's bytes with `images: { 'logo.png': bytes }` +- an image that can't be decoded (the error says which, with the decoding error as its `cause`), a data URI of a type + that isn't supported, or an embedded SVG that can't be read +- images beyond the new `maxImagePixels` option: the pixels of all the raster images the SVG references, decoded + together (default: `maxPixels`; `Image` values in `images` don't count). In 3.x, each image was checked on its own, + so many images could take many times `maxPixels` + +Images that aren't drawn (in ``, `display="none"`, `visibility="hidden"`) don't throw. + +The new `maxLayerPixels` option limits the layers rendering uses at once, the output included: each group with opacity, +a mask, a filter or a blend mode is drawn in a layer of its own (16 bytes per pixel), so nested ones multiply the memory +of a small SVG. The default is `4 * maxPixels`; an SVG over it throws a `RangeError`. For untrusted SVGs, lower +`maxPixels` (it bounds the output, the layers and the images): the worst case is about 90 bytes per pixel of +`maxPixels` (64 for the layers, up to 20 for the images, 4 for the output), so `maxPixels: 4096 ** 2` keeps rendering +around 1.5 GB. + +`Image.renderText()` takes `maxPixels` too (default `16383 ** 2`), which counts the outline (`strokeWidth`), checked +before anything is allocated. + +Search your code for: `renderSVG(` with SVGs that reference files or URLs, or that may contain broken images. + +## 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 +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 FrameDisposalMode}, +{@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 | +| 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`, 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 modes are plain strings in 4.0: the `ResizeModes` and `FrameDisposalModes` classes are removed, and the types +{@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` + +| 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)`, `image.coordinates()` | `for (const [x, y] of image.pixels())`: `pixels()` yields `[x, y, color]` | +| `image.iterateWithColors()` | `image.pixels()`, which yields `[x, y, color]` | + +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` + +`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 | +| `AdjustOptions` (type) | Removed with `absolute`: the channel methods take the factor only | +| `image.red(0.5, { absolute: true })` | See below | + +`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 +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` 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()`. + +### 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({ 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 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>` | + +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` | `GIFAnimationEncodeOptions & AnimationOptions` | +| `APNGAnimationEncoderOptions` | `APNGEncodeOptions & AnimationOptions` | + +Each of them extends the options of the format's still images: `GIFAnimationEncodeOptions` extends +`GIFEncodeOptions`, `APNGEncodeOptions` extends `PNGEncodeOptions` and `WebPAnimationEncodeOptions` extends +`WebPEncodeOptions` (without `threads`: the frames are encoded one at a time). + +### 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` | +| `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({ fit: 'cover' })` | +| `image.cropCircle(true, feathering)` | `image.cropCircle({ fit: 'cover', feathering })` | +| `image.cropCircle(false)` | `image.cropCircle()`, the default `fit: 'contain'` | +| `image.cropCircle({ max: true })` | `image.cropCircle({ fit: 'cover' })` | +| `PNGMetadata` (type) | `Metadata`, which the encoders that write metadata take | + +`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 `fit: '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 RenderSVGOptions} and +{@link FrameOptions}. The animation options are {@link GIFAnimationEncodeOptions}, {@link APNGEncodeOptions}, +{@link DecodeOptions} and {@link GainMapEncodeOptions} for `GainMap.encodeJPEG()`. + +| 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.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 | +| `image.sharpen(2, 3)` | `image.sharpen({ amount: 2, radius: 3 })` | +| `image.dominantColor(true, true, 15)` | `image.dominantColor({ ignoreBlack: true, ignoreWhite: true, threshold: 60 })`: the threshold is in channel values now, see [below](#dominantcolor-thresholds-are-channel-values) | +| `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 })`, `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 | +| `image.encodeJPEG(90)` | `image.encodeJPEG({ 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 })` | `gainMap.encodeJPEG({ quality: 90 })`: renamed for the format it writes, like `Image.encode()` became `encodePNG()` | +| `Image.renderSVG(svg, 512, 'width')` | `Image.renderSVG(svg, { width: 512 })` (and `{ height }`, `{ scale }`) | +| `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)` | +| `decode(data, { onlyFirstFrame: true })` | `Image.decode(data)` | + +`decode()` and `Animation.decode()` take options only: a boolean throws a `TypeError` that names the options form. +`onlyFirstFrame` is not an option anymore, and options that don't exist are ignored without an error: `decode(data, { onlyFirstFrame: true })` +quietly returns an `Animation` for a file with several frames, so search for `onlyFirstFrame` too. + +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 + +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. + +### Methods and options + +| 3.x | 4.0 | +| --- | --- | +| `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 bilinear sampling is the `fast: true` one (the same pixels, `fast: false` is removed: it differed by a step at most). `mode` picks `'nearest-neighbor'`, `'bilinear'` or `'bicubic'`, like `transform()` | +| `resize(w, h, { fast: false })`, `scale(f, { fast: false })`, `{ fast: true }` | `resize(w, h)`, `scale(f)`: `fast` is removed, resizing always works like its default `fast: true` (8-bit rounding between the passes, at most a step or two off the precise result) | +| `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) | +| `Image.renderText(text, { font, size, layout: { maxWidth: 300 } })`, `layout: new TextLayout(options)` | `Image.renderText(text, { font, size, width: 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 | +| `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 | +| `new GainMap(image, { gainMapMin, gainMapMax })`, `map.gainMapMin`, `map.gainMapMax` | `new GainMap(image, { min, max })`, `map.min`, `map.max` | +| `Image.renderSVG(svg, { fonts })` | `Image.renderSVG(svg, { font })`, like `renderText()` (one font or an array). An old `fonts` is ignored, so text isn't drawn: search for it | +| `setPixelAt(x, y, pixelColor)`, `lightness(value)`, `saturation(value)`, `opacity(opacity)` (parameter names only) | `setPixelAt(x, y, color)`, `lightness(factor)`, `saturation(factor)`, `opacity(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. The gain-map parameter properties (`min`, `max`, `gamma`, +`offsetSDR` and `offsetHDR`) return readonly copies: changing `map.min[0]` does not change the map (and TypeScript +rejects it). Assign a scalar or a full tuple instead, such as `map.min = [0, 1, 2]`. + +### 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 | + +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 +left corner stays where it was, and the turned image hangs from it. + +### 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, truncated to an integer for the offsets; 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 })` (`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. + +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). +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({ fit: 'cover' }), not boolean +``` + +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()` 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 + +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_|ResizeModes|FrameDisposalModes' 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|coordinates\(|for \(const \[[a-z]+, ?[a-z]+\] of' src + +# types and classes +grep -rnE 'PNGMetadata|AnimationEncoderOptions|AdjustOptions|TextOptions|FrameDisposalModeId|FrameDisposalModeName|SVGOptions|\bTextStyle\b|\bGIF\b' src + +# decode and animations +grep -rnE 'decode\(.*, *(true|false)\)|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 + +# 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\(|renderSVG\([^)]*(size|mode)|layout:|getRGBAAt|\.region\(' src +grep -rnE 'encodeGIF\(|gifEncoder\(|GIFEncodeOptions|encodeJPEG\(' src + +# 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 'maxWidth|maxHeight|wrapHardBreaks|\.(rotate|quantize|contain|encodeJPEG|dominantColor)\(|from(HSLA?)\(|toHSLA\(' src +grep -rnE 'compression:|[xy]Offset|\.bitmap =|renderSVG\(|Creation Time|DateTime' 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 sixteen behavior changes need +runtime checks: a file with several frames, a one-frame GIF, any `loopCount: -1`, every resize fit mode, every HSL hue, +every `rotate()`, every `dominantColor()` threshold, all text rendered with a size, every JPEG made from an image with transparency, every `quantize()`, the size changes of frames, the +values that now throw, gradients with transparent stops, gain maps given to images, anything that reads the metadata of encoded files, and every SVG with images. diff --git a/jsr.json b/jsr.json index 2b0364bb..39b02154 100644 --- a/jsr.json +++ b/jsr.json @@ -8,8 +8,6 @@ "src", "README.md", "LICENSE", - "LICENSE.MIT", - "LICENSE.AGPL-3.0", "THIRD_PARTY_NOTICES.md", "licenses" ] diff --git a/media/docs.css b/media/docs.css index f6f12e46..5e40f12f 100644 --- a/media/docs.css +++ b/media/docs.css @@ -163,14 +163,6 @@ pre > button { height: var(--toolbar-height); } -.tsd-page-toolbar .table-cell { - line-height: var(--toolbar-height); -} - -.tsd-page-toolbar .tsd-toolbar-icon { - padding: calc((var(--toolbar-height) - 16px) / 2) 0; -} - .tsd-page-toolbar .tsd-widget { height: var(--toolbar-height); } @@ -211,7 +203,6 @@ pre > button { color: var(--color-text-aside); font-size: 0.9rem; font-weight: 500; - margin-left: 1.5rem; } #tsd-toolbar-links a:hover { @@ -219,31 +210,31 @@ pre > button { text-decoration: none; } -#tsd-search .field input { - font: inherit; +/* the search: a dialog the toolbar's button opens */ +#tsd-search { + border-radius: var(--radius); + box-shadow: 0 12px 32px rgb(0 0 0 / 0.16); } -#tsd-search .results { - border-radius: 0 0 var(--radius) var(--radius); - overflow: hidden; - box-shadow: 0 12px 32px rgb(0 0 0 / 0.16); +#tsd-search-input { + font: inherit; } -#tsd-search .results li { +#tsd-search-results > li { line-height: 1.4; } -#tsd-search .results li:nth-child(even) { +#tsd-search-results > li:nth-child(even) { background-color: var(--color-background); } -#tsd-search .results li:nth-child(odd) { +#tsd-search-results > li:nth-child(odd) { background-color: var(--color-background-secondary); } -#tsd-search .results li.current, -#tsd-search .results li:hover { +#tsd-search-results > li:is(:hover, [aria-selected="true"]) { background-color: var(--color-active-menu-item); + color: inherit; } /* ---------- layout ---------- */ @@ -341,11 +332,16 @@ pre > button { font-weight: 600; } -.tsd-navigation .tsd-accordion-summary svg, -.tsd-page-navigation .tsd-accordion-summary svg { - width: 0.75rem; - height: 0.75rem; - margin-right: 0.15rem; +/* typedoc's chevrons are 20px symbols in svgs without a viewBox: zoom scales them (a smaller width or height would only +cut them off). Their margins are zoomed too */ +.tsd-navigation .tsd-accordion-summary > svg { + zoom: 0.56; + margin-right: 0.27rem; + opacity: 0.6; +} + +/* (sized with the rest of the page menu's below) */ +.tsd-page-navigation .tsd-accordion-summary > svg { opacity: 0.6; } @@ -369,9 +365,8 @@ pre > button { } .page-menu .tsd-accordion-summary > svg { - width: 0.7rem; - height: 0.7rem; - margin-right: 0.3rem; + zoom: 0.72; + margin-right: 0.42rem; } .page-menu .tsd-page-navigation a { @@ -480,6 +475,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; } @@ -521,12 +520,9 @@ a.external[target="_blank"]:has(> img) { margin: 0; } -.tsd-accordion-summary > h2 > svg, -.tsd-accordion-summary > h3 > svg, -.tsd-accordion-summary > h5 > svg, +/* (the size the headings' chevrons had at 0.8em of the headings) */ .tsd-accordion-summary > svg { - width: 0.8em; - height: 0.8em; + zoom: 1.59; opacity: 0.45; } diff --git a/package.json b/package.json index 6795b004..3f54f195 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,6 @@ "files": [ "dist", "LICENSE", - "LICENSE.MIT", - "LICENSE.AGPL-3.0", "THIRD_PARTY_NOTICES.md", "licenses", "README.md" @@ -29,7 +27,7 @@ "test": "npm run build && node ./tests/run.js", "coverage": "node scripts/build.js --source-maps && c8 node ./tests/run.js && node scripts/coverage-report.js", "prepack": "npm run build", - "version": "node scripts/version.js && git add src/version.ts jsr.json" + "version": "node scripts/version.js && git add jsr.json" }, "repository": { "type": "git", @@ -79,14 +77,13 @@ "pbm", "pam", "pfm", - "hdr", "radiance", "animation", "text rendering", "font" ], "author": "Mathis Mensing ", - "license": "(AGPL-3.0-or-later OR MIT)", + "license": "MIT", "bugs": { "url": "https://github.com/imagescript/ImageScript/issues" }, @@ -95,12 +92,19 @@ "node": ">=22.13.0" }, "devDependencies": { - "@eslint/js": "9.39.5", + "@eslint/js": "10.0.1", "c8": "12.0.0", - "eslint": "9.39.5", + "eslint": "10.14.0", "eslint-plugin-sonarjs": "4.2.2", - "typedoc": "0.27.9", - "typescript": "5.7.3", - "typescript-eslint": "8.72.0" + "typedoc": "0.28.20", + "typescript": "6.0.3", + "typescript-eslint": "8.72.2" + }, + "overrides": { + "minimatch": "11.0.8", + "glob": "14.0.5", + "typedoc": { + "minimatch": "9.0.9" + } } } diff --git a/scripts/build.js b/scripts/build.js index 670fdcd7..9f5843d6 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -8,7 +8,7 @@ const root = path.join(__dirname, '..'); const tsc = require.resolve('typescript/bin/tsc'); const run = args => execFileSync(process.execPath, [tsc, ...args], { cwd: root, stdio: 'inherit' }); -// keep the version in PNG metadata and the one JSR publishes in sync with package.json +// keep the version JSR publishes in sync with package.json require('./version.js').setVersion(); fs.rmSync(path.join(root, 'dist'), { recursive: true, force: true }); @@ -52,5 +52,6 @@ const threadsCode = fs.readFileSync(threads, 'utf8'); if (!threadsCode.includes("new URL('./worker.ts', import.meta.url)")) throw new Error('build: the worker URL in threads.js not found'); fs.writeFileSync(threads, threadsCode.replace("new URL('./worker.ts', import.meta.url)", "new URL('./worker.js', import.meta.url)")); -// the package itself has no "type", so mark the build as ES modules -fs.writeFileSync(path.join(root, 'dist/package.json'), '{"type":"module"}\n'); +// the package itself has no "type", so mark the build as ES modules. Bundlers read "sideEffects" from the nearest +// package.json, which this one is for everything in dist/: without it they keep every module of the library +fs.writeFileSync(path.join(root, 'dist/package.json'), '{"type":"module","sideEffects":false}\n'); diff --git a/scripts/docs.js b/scripts/docs.js index 73186fbe..8cee4d41 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); @@ -63,8 +63,9 @@ 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], - 'TextLayout.html': ['classes/TextLayout.html', true], + 'GIF.html': ['classes/Animation.html', true], + // (TextLayout isn't exported any more: its options are renderText()'s) + 'TextLayout.html': ['interfaces/TextLayoutOptions.html', false], 'global.html': ['functions/decode.html', false] }; 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 diff --git a/scripts/version.js b/scripts/version.js index 982e0a88..c4abdfc2 100644 --- a/scripts/version.js +++ b/scripts/version.js @@ -1,6 +1,6 @@ -// sets the package version and keeps its copies in sync: package.json, jsr.json and src/version.ts (reported in -// PNG metadata). The release workflow sets the version from the release tag, the build syncs the copies. -// usage: node scripts/version.js [version] (without a version only the copies are synced to package.json) +// sets the package version and keeps its copy in sync: package.json and jsr.json. The release workflow sets the +// version from the release tag, the build syncs the copy. +// usage: node scripts/version.js [version] (without a version only the copy is synced to package.json) const fs = require('fs'); const path = require('path'); @@ -15,10 +15,6 @@ function setVersion(version) { const jsr = readJSON('jsr.json'); if (jsr.version !== version) writeJSON('jsr.json', { ...jsr, version }); - - const file = path.join(root, 'src/version.ts'); - const source = `// kept in sync with package.json by scripts/version.js\nexport const VERSION = '${version}';\n`; - if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== source) fs.writeFileSync(file, source); return version; } diff --git a/src/animation-encoder.ts b/src/animation-encoder.ts index bc67fcfc..594dcee9 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'; @@ -6,56 +7,40 @@ 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, fitLoops } from './util/options.ts'; -import type { AnimationOptions, AnimationEncodeOptions, 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; +import { checkOptions, finite, compressionLevel, loopCount, toPlays, fitLoops, fitDuration, maxDuration } from './util/options.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 { + readonly name: 'GIF' | 'APNG' | 'WebP'; readonly optimize: OptimizeOptions; frame(f: OptimizedFrame | InputFrame, optimized: boolean): Promise; - file(optimizer: Optimizer | null): Promise | Uint8Array; + file(optimizer: Optimizer | null): Promise> | Uint8Array; +} + +/** + * A frame cut to the canvas: as it is when it lies inside, else the part inside (a copy), or a transparent pixel when + * it lies entirely outside (it shows nothing, its duration counts), like {@link Animation}'s encoders do + */ +function clipToCanvas(frame: InputFrame & { disposal: number }, width: number, height: number): InputFrame & { disposal: number } { + const { x, y, width: w, height: h } = frame; + if (x >= 0 && y >= 0 && x + w <= width && y + h <= height) return frame; + const left = Math.max(0, x), top = Math.max(0, y), right = Math.min(width, x + w), bottom = Math.min(height, y + h); + if (right <= left || bottom <= top) return { ...frame, x: 0, y: 0, width: 1, height: 1, disposal: 1, dispose: 0, pixels: new Uint8Array(4) }; + const pixels = new Uint8Array((right - left) * (bottom - top) * 4); + for (let row = top; row < bottom; row++) + pixels.set(frame.pixels.subarray(((row - y) * w + left - x) * 4, ((row - y) * w + right - x) * 4), (row - top) * (right - left) * 4); + return { ...frame, x: left, y: top, width: right - left, height: bottom - top, pixels }; } /** * Encodes an animation a frame at a time, without keeping all frames in memory: see {@link Animation.webpEncoder}, - * {@link Animation.gifEncoder} and {@link Animation.apngEncoder}. The file is the one an {@link Animation} of the - * frames encodes to, but for unoptimized WebPs with frames WebP can't store as they are (disposed to the previous - * frame, or disposed with odd offsets): from the first of them on, the frames are the canvas they show (the same - * animation, blending rounds differently by 1 at most). APNGs keep their frames compressed until the end: their pixel - * format depends on all of them + * {@link Animation.gifEncoder} and {@link Animation.apngEncoder} + * @remarks + * The file is the one an {@link Animation} of the frames encodes to, but for unoptimized WebPs with frames WebP can't + * store as they are (disposed to the previous frame, or disposed with odd offsets): from the first of them on, the + * frames are the canvas they show (the same animation, blending rounds differently by 1 at most). APNGs keep their + * frames compressed until the end: their pixel format depends on all of them */ export class AnimationEncoder { private optimizer: Optimizer | null; @@ -68,8 +53,10 @@ export class AnimationEncoder { /** The canvas height */ readonly height: number; - /** @internal */ - constructor(width: number, height: number, private readonly format: Format, optimize: boolean) { + // (private: created by the factories of Animation, through the static methods below; a constructor marked for + // stripping would be left out of the declarations, which then show a public `new AnimationEncoder()` that fails) + private constructor(width: number, height: number, private readonly format: Format, optimize: boolean) { + if (!format) throw new TypeError('AnimationEncoder is created by Animation.gifEncoder(), apngEncoder() or webpEncoder()'); this.width = width; this.height = height; this.optimizer = optimize ? new Optimizer(width, height, format.optimize) : null; @@ -80,47 +67,49 @@ 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 * @param frame The frame or image * @param options See {@link FrameOptions} + * @throws `TypeError` if the frame isn't a {@link Frame} or an {@link Image} + * @throws `RangeError` if the duration is negative or more than the format stores + * @throws `Error` if the encoder is finished */ - add(frame: Frame | Image, options: FrameOptions = {}): Promise { + async 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 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'); - finite(x, 'x offset'); - finite(y, 'y offset'); + const image = own ? own.image : frame; + if (!(image instanceof Image)) throw new TypeError('encoder.add() takes a Frame or an Image'); + const duration = finite(options.duration === undefined ? own?.duration ?? 100 : options.duration, 'frame duration'); + if (duration < 0) throw new RangeError('Invalid frame duration'); + fitDuration(duration, this.format.name); + // (offsets are truncated to integers, like a frame's) + const x = Math.trunc(finite(options.xOffset === undefined ? own?.xOffset ?? 0 : options.xOffset, 'x offset')) || 0; + const y = Math.trunc(finite(options.yOffset === undefined ? own?.yOffset ?? 0 : options.yOffset, 'y offset')) || 0; 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++; + // (without the optimizer, which cuts frames to the canvas, a frame is cut here, like Animation does) + const given = this.optimizer ? input : clipToCanvas(input, this.width, this.height); return this.queue = this.queue.then(async () => { - if (!this.optimizer) return this.format.frame(input, false); + if (!this.optimizer) return this.format.frame(given, false); this.optimizer.push(input); for (const f of this.optimizer.take()) await this.format.frame(f, true); }); } - /** Encodes the rest and returns the file. The encoder can't be used afterwards */ - async finish(): Promise { + /** + * Encodes the rest and returns the file. The encoder can't be used afterwards + * @throws `Error` if no frame was added, or the encoder is finished already + */ + async finish(): Promise> { if (this.finished) throw new Error('The animation encoder is finished'); this.finished = true; await this.queue; @@ -132,9 +121,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); - fitLoops(loopCount, 'WebP'); + static __webp__(width: number, height: number, { loopCount: count = Infinity, metadata = {}, optimize = true, quality, effort = 2, sharpYuv = false, alphaQuality = 100 }: WebPAnimationEncodeOptions & AnimationOptions): AnimationEncoder { + const loops = fitLoops(loopCount(count), 'WebP'); + canvas(width, height, 'WebP'); 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[] = []; @@ -148,7 +137,8 @@ export class AnimationEncoder { chunks.push(await webp.frameChunk(f, lossy, effort)); }; return new AnimationEncoder(width, height, { - optimize: { replace: true, even: true, alpha: 'frame', tolerance: lossy ? webp.lossyTolerance(lossy.quality) : undefined }, + name: 'WebP', + optimize: { replace: true, even: true, alpha: 'frame', tolerance: lossy ? webp.lossyTolerance(lossy.quality) : undefined, maxDuration: maxDuration('WebP') }, async frame(f, optimized) { if (optimized) return add(f as OptimizedFrame); const input = f as InputFrame & { disposal: number }; @@ -157,21 +147,23 @@ 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 { - const quality = finite(qualityOption, 'quality'); - canvas(width, height, loopCount); - fitLoops(loopCount, 'GIF'); + 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, 'GIF'); 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; const rest: Uint8Array[] = []; return new AnimationEncoder(width, height, { - optimize: { replace: false, even: false, alpha: 'frame', palette: 256, cover: true }, + name: 'GIF', + optimize: { replace: false, even: false, alpha: 'frame', palette: 256, cover: true, maxDuration: maxDuration('GIF') }, async frame(f, optimized) { if (!first && optimized) coverCanvas(f as OptimizedFrame, width, height); const gif: gifCodec.GIFFrame = { x: f.x, y: f.y, width: f.width, height: f.height, pixels: f.pixels, @@ -180,20 +172,20 @@ 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, 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, palette, 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); - fitLoops(loopCount, 'APNG'); + const loops = fitLoops(loopCount(count), 'APNG'); + canvas(width, height, 'APNG'); 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 @@ -201,7 +193,8 @@ export class AnimationEncoder { const cost = new png.AlphaCost(); let blended = false; return new AnimationEncoder(width, height, { - optimize: { replace: true, even: false, alpha: () => cost.cost() }, + name: 'APNG', + optimize: { replace: true, even: false, alpha: () => cost.cost(), maxDuration: maxDuration('APNG') }, async frame(f, optimized) { let frame: png.APNGEncodeFrame; if (optimized) { @@ -232,15 +225,16 @@ 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, format: 'GIF' | 'APNG' | 'WebP'): 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'); + // (the GIF header stores the size in 16 bits, WebP in 24; larger ones would wrap) + if (format === 'GIF' && (width > 0xffff || height > 0xffff)) throw new RangeError(`GIF: images can be at most 65535 pixels wide and high, not ${width}x${height}`); } // a frame in the WebP codec's terms (odd offsets: one transparent pixel more on the left/top, blending makes it @@ -257,7 +251,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/animation.ts b/src/animation.ts index 3200969c..29309b5a 100644 --- a/src/animation.ts +++ b/src/animation.ts @@ -6,314 +6,555 @@ 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, ResizeMode, ResizeModes, ResizeOptions, ResizeToOptions, resizeArguments } from './image.ts'; -import { Frame, FrameDisposalModes } from './frame.ts'; +import { Image, DecodeOptions, EncodeOptions, GIFEncodeOptions, ResizeOptions, ResizeToOptions, RotateOptions, TransformOptions, AffineTransform, ShadowOptions, OutlineOptions, TrimOptions, resizeArguments, insideSize, alignedPixels, PNGEncodeOptions, WebPEncodeOptions, FlipDirection, FLIP_DIRECTIONS, TONE_MAPPINGS } 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 { isOptions, finite, compressionLevel, fitLoops } from './util/options.ts'; -import { MAX_PIXELS, decoding } from './util/limits.ts'; +import { paletteOptions } from './codecs/quantize.ts'; +import { resolveMetadata, pngText, exifData, gifComments } from './metadata.ts'; +import { checkOptions, oneOf, pixelLimit, checkThreads, finite, compressionLevel, loopCount, toPlays, fromPlays, fitLoops, fitDuration, maxDuration, color as checkColor } from './util/options.ts'; +import { MAX_PIXELS, decoding, checkPixels } 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; - // 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); // the composited frames are copies already, the frame can keep them - frame.bitmap = pixels; - return frame; + const image = new Image(width, height); + image.bitmap = alignedPixels(pixels); + return new Frame(image, { duration, disposalMode: 'background' }); }); } /** 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 + * @defaultValue `Infinity` */ loopCount?: number; } -/** 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 - * bombs) can't exhaust the memory. Default: `268402689` (16383², like sharp), `Infinity` for no limit - */ - 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; +/** Options of {@link Animation}'s constructor: the canvas size and the loop count */ +export interface AnimationCanvasOptions extends AnimationOptions { /** - * 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` + * The canvas width in pixels: frames (or the parts of them) outside the canvas aren't encoded + * @defaultValue The frames' right edge (the largest `xOffset + width`), following the frames until a canvas size is + * set */ - applyOrientation?: boolean; + width?: number; /** - * 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'` + * The canvas height in pixels, like `width` + * @defaultValue The frames' bottom edge (the largest `yOffset + height`) */ - 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; + height?: number; } /** 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 - * frames without it, which also encodes faster when little changes. Default: `true` + * frames without it, which also encodes faster when little changes. The exception is GIF, which has no partial + * transparency: without `optimize`, a semi-transparent pixel becomes opaque or transparent in its own frame, with + * it the frames are composited first + * @defaultValue `true` */ optimize?: boolean; } -/** 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) - */ - quality?: number; - /** How much effort the frames take (0-6, lossy and lossless), see {@link WebPEncodeOptions.effort}. Default: `2` */ - effort?: number; - /** Lossy frames: libwebp's sharp YUV conversion, see {@link WebPEncodeOptions.sharpYuv}. Default: `false` */ - sharpYuv?: boolean; - /** Lossy frames: the alpha planes' quality (0-100), see {@link WebPEncodeOptions.alphaQuality}. Default: `100` */ - alphaQuality?: number; -} +/** + * Options of {@link Animation.encodeWebP} and {@link Animation.webpEncoder}: those of {@link WebPEncodeOptions} for every + * frame (but `threads`: the frames are encoded one at a time), and the animation options. Without `quality` the frames + * are lossless + */ +export interface WebPAnimationEncodeOptions extends Omit, AnimationEncodeOptions {} -/** 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}: those of {@link GIFEncodeOptions} for every + * frame, and the animation options + */ +export interface GIFAnimationEncodeOptions extends GIFEncodeOptions, AnimationEncodeOptions {} -/** Options of {@link Animation.encodeAPNG} */ -export interface APNGEncodeOptions extends AnimationEncodeOptions { - /** The zlib compression level (0-9), higher is smaller but slower. Default: `4` */ - compression?: number; -} +/** + * Options of {@link Animation.encodeAPNG} and {@link Animation.apngEncoder}: those of {@link PNGEncodeOptions}, and the + * animation options + */ +export interface APNGEncodeOptions extends PNGEncodeOptions, 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 { - /** - * 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; +export class Animation { + /** @internal @ignore */ + private __loopCount__ = Infinity; + + /** @internal @ignore */ + private __animationSignal__ = false; + + /** @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; /** * Creates a new animation + * @example + * ```ts + * const animation = new Animation([ + * new Frame(new Image(64, 64).fill(0xff0000ff), { duration: 200 }), + * new Frame(new Image(64, 64).fill(0x0000ffff), { duration: 200 }) + * ], { loopCount: 2 }); + * const gif = await animation.encodeGIF(); + * ``` * @param frames The frames to create the animation from - * @param options See {@link AnimationOptions} - */ - constructor(frames: Frame[], options?: AnimationOptions); + * @param options The canvas size and the loop count, see {@link AnimationCanvasOptions} + */ + constructor(frames: Frame[], options?: AnimationCanvasOptions); + constructor(frames: Frame[], options: AnimationCanvasOptions = {}) { + checkOptions(options, 'Animation(frames, { loopCount: 2 })'); + 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; + } + /** - * 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 + * 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 */ - constructor(frames: Frame[], loopCount?: number); - constructor(frames: Frame[], loopCountOrOptions: number | AnimationOptions = -1) { - super(); - const { loopCount = -1 } = isOptions(loopCountOrOptions) ? loopCountOrOptions : { loopCount: loopCountOrOptions }; - // 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`); - - if (loopCount !== Infinity && (finite(loopCount, 'loop count') < -1 || !Number.isInteger(Number(loopCount)))) - throw new RangeError(`Invalid loop count: ${loopCount}`); - - this.loopCount = loopCount; + get loopCount(): number { + return this.__loopCount__; } /** - * @internal - * @ignore + * 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 */ - static override get [Symbol.species](): ArrayConstructor { - return Array; + set loopCount(value: number) { + this.__loopCount__ = loopCount(value); } /** - * 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 as a frozen array. The frames themselves are mutable; assign a new array to replace the + * list */ - get frames(): Frame[] { - return this; + 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[]) { - 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); + 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 */ + 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 */ + /** + * 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]; 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 */ + /** 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) max = Math.max(max, frame.height + frame.yOffset); + 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; - 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), 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); - } - /** * 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. + * @remarks + * With `fit: 'contain'` and a `background` that isn't transparent, the frames are composited onto it, so the animation + * has whole frames afterwards (the file shows the same, but it can't keep the frames' parts apart anymore) * @param width The new width * @param height The new height * @param options See {@link ResizeOptions} + * @throws `RangeError` if the width or height is less than 1 pixel, or the animation has no canvas */ 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 { - // eslint-disable-next-line prefer-const - let [width, height, modeOrOptions] = resizeArguments(widthOrOptions, heightArgument, modeArgument); + 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 (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; + if (!originalWidth || !originalHeight) throw new RangeError('An animation without frames or canvas can\'t be resized'); + // (a side that is given has to be a pixel at least, like Image.resize() wants; one that follows the other can't be less) + if (givenWidth !== undefined && Math.floor(givenWidth) < 1) throw new RangeError('The animation has to be at least 1 pixel wide'); + if (givenHeight !== undefined && Math.floor(givenHeight) < 1) throw new RangeError('The animation has to be at least 1 pixel high'); + // the size of the canvas as Image.resize() works it out: a side that is given is cut to whole pixels, one left + // out follows the other, keeping the aspect ratio + let width: number, height: number; + if (fit === 'inside' && givenWidth !== undefined && givenHeight !== undefined) [width, height] = insideSize(originalWidth, originalHeight, givenWidth, givenHeight).map(Math.floor); + else if (givenWidth === undefined) { + height = Math.floor(givenHeight as number); + width = Math.max(1, Math.round(originalWidth / originalHeight * height)); + } else if (givenHeight === undefined) { + width = Math.floor(givenWidth); + height = Math.max(1, Math.round(originalHeight / originalWidth * width)); + } else [width, height] = [Math.floor(givenWidth), Math.floor(givenHeight)]; + const canvas = canvasSize(Math.max(1, width), Math.max(1, height)); const sx = width / originalWidth, sy = height / originalHeight; - for (const frame of this) { + // (a frame listed twice, or two frames of one image, change once: the sizes and offsets are worked out before anything does) + const changes = this.__distinct__().map(frame => { // 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); + return { frame, left, top, width: Math.max(1, right - left), height: Math.max(1, bottom - top) }; + }); + const shared = new Set(), seen = new Set(); + for (const { frame } of changes) (seen.has(frame.image) ? shared : seen).add(frame.image); + const originals = new Map(Array.from(shared, image => [image, image.clone()])); + const resized = new Map(); + for (const { frame, left, top, width, height } of changes) { + const size = `${width}x${height}`, done = resized.get(frame.image), original = originals.get(frame.image); + if (done === undefined) { + resized.set(frame.image, size); + frame.image.resize(width, height, options); + } else if (done !== size && original) { + // (the frame's image was resized already: the copy of the original is what the others start from) + frame.image = original.clone().resize(width, height, options); + } 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 { + // (the part of the region that lies on the canvas) + const left = Math.max(0, Math.floor(finite(x, 'x'))), top = Math.max(0, Math.floor(finite(y, 'y'))); + const right = Math.min(this.width, Math.floor(finite(x, 'x')) + Math.floor(finite(width, 'width'))); + const bottom = Math.min(this.height, Math.floor(finite(y, 'y')) + Math.floor(finite(height, 'height'))); + if (!(right - left >= 1 && bottom - top >= 1)) throw new RangeError('The crop region has to overlap the canvas'); + for (const frame of this.__distinct__()) { + frame.xOffset -= left; + frame.yOffset -= top; + } + this.__canvas__ = [right - left, bottom - top]; + this.frames = this.__clipped_frames__(); + return this; + } + + /** + * Resizes and centers the animation in a box of exactly that size, with transparent or colored bars + * @internal + */ + 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'))); + 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.__distinct__()) { + frame.xOffset += x; + frame.yOffset += y; + } + this.__canvas__ = [boxWidth, boxHeight]; + if (backgroundColor !== 0) { + this.frames = this.__flattened__().map(frame => { + frame.image = new Image(boxWidth, boxHeight).fill(backgroundColor).composite(frame.image); + return frame; + }); + } + 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); + } + + /** + * Flips / mirrors the animation horizontally or vertically, like {@link Image.flip}: every frame is flipped and + * moved to the mirrored place on the canvas, which keeps its size + * @param direction The direction to flip, see {@link FlipDirection} + */ + flip(direction: FlipDirection): this { + oneOf(direction, FLIP_DIRECTIONS, 'flip direction'); + const width = this.width, height = this.height; + // (a frame listed twice, or two frames of one image, are flipped once) + const flipped = new Set(); + for (const frame of this.__distinct__()) { + if (!flipped.has(frame.image)) frame.image.flip(direction); + flipped.add(frame.image); + if (direction === 'horizontal') frame.xOffset = width - frame.xOffset - frame.width; + else frame.yOffset = height - frame.yOffset - frame.height; + } + // (the canvas stays: one that followed the frames would follow the mirrored ones elsewhere) + if (width && height) this.__canvas__ = [width, height]; + 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}`); + // (a side doesn't shrink below one pixel, like Image.scale()) + return this.resize(Math.max(1, this.width * factor), Math.max(1, 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: AffineTransform, 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 = alignedPixels(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; } /** + * The frames without repeats: a frame can be listed several times (to hold it, or in a ping-pong), and a change to + * it has to apply once * @internal * @ignore */ - protected __validate__(): void { - for (const frame of this) - if (!(frame instanceof Frame)) throw new Error('Animation contains invalid frames'); + __distinct__(): Frame[] { + return [...new Set(this.__frames__)]; } /** - * 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 options See {@link GIFEncodeOptions} + * 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 */ - encodeGIF(options?: GIFEncodeOptions): Promise; + __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(new Image(1, 1), { ...options, disposalMode: 'keep' }); + 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 }); + }); + } + + /** + * @internal + * @ignore + */ + protected __validate__(format: 'GIF' | 'APNG' | 'WebP'): void { + if (!this.length) throw new Error('An animation needs at least one frame'); + for (const frame of this.frames) { + if (!(frame instanceof Frame)) throw new Error('Animation contains invalid frames'); + fitDuration(frame.duration, format); + } + const { width, height } = this; + if (width < 1 || height < 1) throw new RangeError('The animation has no canvas: every frame lies outside of it'); + if (format === 'GIF' && (width > 0xffff || height > 0xffff)) throw new RangeError(`GIF: images can be at most 65535 pixels wide and high, not ${width}x${height}`); + if (format === 'WebP' && (width > 1 << 24 || height > 1 << 24)) throw new RangeError('WebP: canvas too large'); + } + /** * 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 }; - fitLoops(this.loopCount, 'GIF'); - this.__validate__(); + * @param options See {@link GIFAnimationEncodeOptions} + * @throws `Error` if the animation has no frames + * @throws `RangeError` if the loop count or a frame's duration is more than GIF stores, or the canvas is wider or + * higher than 65535 pixels + */ + 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__('GIF'); 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 => ({ + ? this.__optimized__({ replace: false, even: false, alpha: 'frame', palette: 256, cover: true, maxDuration: maxDuration('GIF') }).map(f => ({ ...f, dispose: f.dispose ? 3 : 1 })) + : Array.from(this.__clipped_frames__(), frame => ({ x: frame.xOffset, y: frame.yOffset, width: frame.width, 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) })); - 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 }, { colors: palette.colors as number, dither: palette.dither, strength: palette.strength }, gifComments(resolveMetadata(metadata))); } /** @@ -322,27 +563,20 @@ export class Animation extends Array { * merged (unless the `optimize` option is false): the file shows exactly the same animation, but doesn't * necessarily decode to the same frames * @param options See {@link APNGEncodeOptions} + * @throws `Error` if the animation has no frames + * @throws `RangeError` if the loop count or a frame's duration is more than APNG stores */ - 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 }; - fitLoops(this.loopCount, 'APNG'); - this.__validate__(); - if (!this.length) throw new Error('An animation needs at least one frame'); + 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'); + this.__validate__('APNG'); 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.__optimized__({ replace: true, even: false, alpha: frames => png.alphaCost(frames.map(f => f.pixels)), maxDuration: maxDuration('APNG') }).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))); } /** @@ -350,16 +584,19 @@ export class Animation extends Array { * changed since the frame before, identical frames are merged (unless the `optimize` option is false): the file * 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 { - fitLoops(this.loopCount, 'WebP'); - this.__validate__(); - if (!this.length) throw new Error('An animation needs at least one frame'); + * @throws `Error` if the animation has no frames + * @throws `RangeError` if the loop count or a frame's duration is more than WebP stores + */ + 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__('WebP'); 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); + const frames = optimize ? this.__optimized__({ replace: true, even: true, alpha: 'frame', tolerance, maxDuration: maxDuration('WebP') }) : this.__webpFrames__(); + return webp.encodeAnimation(frames, this.width, this.height, toPlays(loops), exifData(resolveMetadata(metadata)), lossy, effort); } /** @@ -369,14 +606,15 @@ 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 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, 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); + 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; @@ -405,7 +643,7 @@ export class Animation extends Array { */ __apngFrames__(): png.APNGFrame[] { const dispose = [0, 0, 2, 1]; - return Array.from(this, frame => ({ + return Array.from(this.__clipped_frames__(), frame => ({ x: frame.xOffset, y: frame.yOffset, width: frame.width, @@ -413,7 +651,7 @@ export class Animation extends Array { 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) })); } @@ -421,11 +659,19 @@ export class Animation extends Array { * An encoder that makes an animated WebP a frame at a time, like {@link Animation.encodeWebP} (the same file), * without keeping the frames in memory: `await encoder.add(frame)` for each frame (a {@link Frame}, or an image * with `{ duration }`), then `await encoder.finish()` for the file + * @example + * ```ts + * const encoder = Animation.webpEncoder(64, 64, { quality: 80 }); + * for (const color of [0xff0000ff, 0x00ff00ff, 0x0000ffff]) + * await encoder.add(new Image(64, 64).fill(color), { duration: 200 }); + * const webp = await encoder.finish(); + * ``` * @param width The canvas width * @param height The canvas height * @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); } @@ -434,9 +680,10 @@ export class Animation extends Array { * 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 { + static gifEncoder(width: number, height: number, options: GIFAnimationEncodeOptions & AnimationOptions = {}): AnimationEncoder { + checkOptions(options, 'Animation.gifEncoder(width, height, { colors: 64 })'); return AnimationEncoder.__gif__(width, height, options); } @@ -449,105 +696,126 @@ export class Animation extends Array { * @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); } /** * Decodes an animated GIF, APNG, WebP or JPEG XL into fully composited frames (still images become a single frame) + * @example + * ```ts + * const animation = await Animation.decode(gifBytes); + * const webp = await animation.resize({ width: 320 }).encodeWebP({ quality: 75 }); + * ``` + * @remarks An AVIF image sequence is decoded as its first frame only * @param input The binary data to decode - * @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 + * @param options See {@link DecodeOptions} + * @throws {@link LimitError} if the frames have more than `options.maxPixels` pixels together + * @throws `Error` if the data isn't a supported image, or is damaged or truncated */ - static decode(input: BinaryData, onlyExtractFirstFrame?: boolean): Promise; - static async decode(input: BinaryData, onlyFirstFrameOrOptions: boolean | AnimationDecodeOptions = false): 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) - 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, { maxPixels: 1e8 })'); + const { maxPixels = MAX_PIXELS, threads = 1 } = options; + checkThreads(threads); + pixelLimit(maxPixels); + const { toneMapping = 'reinhard' } = options; + oneOf(toneMapping, TONE_MAPPINGS, 'toneMapping'); + return decoding(() => Animation.__decode__(input, options)); + } + + /** @internal @ignore */ + static __is_animated__(animation: Animation): boolean { + return animation.__animationSignal__; } /** * @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: 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); const type = ImageType.getType(data); - if (type === 'gif') return Animation.__fromGIF__(data, onlyExtractFirstFrame, Animation, maxPixels); + if (type === 'gif') return Animation.__gifFrames__(data, maxPixels); if (type === 'webp') { - const decoded = webp.decodeAnimation(data, onlyExtractFirstFrame, maxPixels); + 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, fromPlays(decoded.loopCount)); + 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) 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: Infinity, width: image.width, height: image.height }); } 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); + const decoded = await png.decodeAnimation(data, false, maxPixels); + // (composited as they are decoded: the raw frames don't pile up) + const step = png.compositor(decoded.width, decoded.height), shown: Array<{ pixels: Uint8Array, duration: number }> = []; + for await (const frame of decoded.frames) shown.push({ pixels: step(frame), duration: frame.duration }); + const frames = orient(profile(canvasFrames(decoded.width, decoded.height, shown), decoded.icc), decoded.orientation); + 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 loopCount = -1, 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 frame = new Frame(decoded.width, decoded.height, decoded.duration, 0, 0, FrameDisposalModes.BACKGROUND); - frame.bitmap = decoded.pixels; - frames.push(frame); + const image = new Image(decoded.width, decoded.height); + image.bitmap = alignedPixels(decoded.pixels); + frames.push(new Frame(image, { duration: decoded.duration, disposalMode: 'background' })); + // (all frames count, like those of the other formats) + checkPixels(decoded.width, decoded.height, maxPixels, frames.length); orientation = decoded.orientation; - loopCount = fromPlays(decoded.loopCount); + loops = fromPlays(decoded.loopCount); + animated ||= decoded.animated; 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; 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, frames.length > 1 ? loopCount : -1); + 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) return new Animation([Frame.from(await Image.decode(data, { maxPixels, applyOrientation, applyColorProfile, toneMapping, gainMap, threads }), 0)]); + if (type) { + const image = await Image.decode(data, { maxPixels, applyOrientation, applyColorProfile, toneMapping, gainMap, threads }); + return new Animation([new Frame(image, { duration: 0 })], { width: image.width, height: image.height }); + } throw new Error('Unsupported image type'); } @@ -555,17 +823,17 @@ export class Animation extends Array { * @internal * @ignore */ - static __fromGIF__(data: Uint8Array, onlyExtractFirstFrame: boolean, Class: new (frames: Frame[], loopCount: number) => 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[], loopCount: number) => T, maxPixels: number): T { - 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 Class(canvasFrames(decoded.width, decoded.height, frames), decoded.loopCount); + 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; } } + +// 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]; +} diff --git a/src/codecs/av1/decode.ts b/src/codecs/av1/decode.ts index b7e9392c..198e885a 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) { @@ -1433,7 +1433,7 @@ export class TileDecoder { const eobTok = m.symbol(cdf, eobCdf + ctx * 4, 2); let tok = eobTok + 1; let levelTok = tok * 0x41; - let mag = 0; + let mag: number; let scanTable: Uint16Array | null = null, stride: number, shift: number, shift2: number, mask: number, offsets = 0; if (txClass === TX_CLASS_2D) { diff --git a/src/codecs/av1/encode.ts b/src/codecs/av1/encode.ts index 4cbdb7e9..b85b3e16 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); @@ -1020,7 +1020,7 @@ class TileEncoder { const sx = plane ? this.ssx : 0, sy = plane ? this.ssy : 0; // (the visible part) const vw = Math.max(0, Math.min(w, (f.width + sx >> sx) - px)), vh = Math.max(0, Math.min(h, (f.height + sy >> sy) - py)); - let eob = -1; + let eob: number; if (!(this.w && b.skip)) { const res = this.res, coefs = this.coefs, q = this.q; let sse = 0; @@ -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..723265bc 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++) { @@ -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; @@ -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/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 6b5e8424..f5f90e31 100644 --- a/src/codecs/gif.ts +++ b/src/codecs/gif.ts @@ -1,4 +1,5 @@ import { ByteWriter } from '../util/bytes.ts'; +import type { DitherMethod } from '../image.ts'; import { quantize } from './quantize.ts'; import { MAX_PIXELS, PixelBudget, checkPixels } from '../util/limits.ts'; @@ -8,6 +9,7 @@ const DISPOSAL_FROM_GIF = [0, 1, 3, 2, 0, 0, 0, 0]; const MAX_CODE = 4095; const UNEXPECTED_END = new Error('GIF: unexpected end of data'); +const EMPTY = new Uint8Array(0); export interface GIFFrame { x: number; @@ -25,7 +27,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[]; } @@ -51,6 +53,7 @@ export interface DecodedGIF { width: number; height: number; loopCount: number; + hasLoopExtension: boolean; frames: DecodedGIFFrame[]; } @@ -60,22 +63,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 -1 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 = -1, 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; @@ -99,7 +104,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); } @@ -111,9 +116,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))); @@ -219,10 +224,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); @@ -237,6 +242,7 @@ export function decode(data: Uint8Array, onlyFirstFrame = 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); @@ -244,18 +250,18 @@ export function decode(data: Uint8Array, onlyFirstFrame = false, maxPixels = MAX try { parse(); } catch (err) { - // truncated files keep every frame decoded so far, like browsers do - if (err !== UNEXPECTED_END) throw err; + // truncated files keep every frame decoded so far, like browsers do; one without a whole frame has no image + if (err !== UNEXPECTED_END || !frames.length) throw err; } 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) { const block = reader.u8(); - if (block === 0x3b) break; // trailer + if (block === 0x3b) return; // trailer if (block === 0x21) { const label = reader.u8(); @@ -275,12 +281,13 @@ export function decode(data: Uint8Array, onlyFirstFrame = 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); 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); @@ -291,7 +298,7 @@ export function decode(data: Uint8Array, onlyFirstFrame = false, maxPixels = MAX if (block !== 0x2c) { // unknown block, stop gracefully with whatever frames were decoded - if (frames.length) break; + if (frames.length) return; throw new Error(`GIF: unknown block type 0x${block.toString(16)}`); } @@ -329,8 +336,10 @@ 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) return; } + // (no trailer: the data was cut off) + throw UNEXPECTED_END; } } @@ -362,6 +371,8 @@ export function composite({ width, height, frames }: DecodedGIF): Array<{ pixels } output.push({ pixels: new Uint8Array(canvas.slice().buffer), delay: frame.delay }); + // (its indices aren't needed anymore: let them go, so they don't add up with the composited frames) + frame.indices = EMPTY; if (frame.dispose === 3) { for (let y = y0; y < y1; y++) canvas.fill(0, y * width + x0, y * width + x1); @@ -454,11 +465,19 @@ function readSubBlocks(reader: ByteReader): Uint8Array { const blocks = []; let total = 0; - for (let length; reader.offset < reader.data.length && (length = reader.u8());) { + // (the terminating zero length block: without it the data was cut off, and the image data is incomplete) + let terminated = false; + while (reader.offset < reader.data.length) { + const length = reader.u8(); + if (!length) { + terminated = true; + break; + } const block = reader.slice(length); blocks.push(block); total += block.length; } + if (!terminated) throw UNEXPECTED_END; const out = new Uint8Array(total); let offset = 0; 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 303a3abd..b4f5977d 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) @@ -173,6 +173,7 @@ export function decode(data: Uint8Array, { transform, maxPixels = MAX_PIXELS }: case 0xc0: case 0xc1: case 0xc2: { // SOF baseline, extended, progressive if (frame) throw new Error('JPEG: multiple frames are not supported'); + if (end - start < 6) throw new Error('JPEG: truncated frame header'); if (data[start] !== 8) throw new Error(`JPEG: ${data[start]}-bit precision is not supported`); checkPixels(u16(start + 3), u16(start + 1), maxPixels); frame = createFrame(marker === 0xc2, u16(start + 1), u16(start + 3), data.subarray(start + 5, end)); @@ -257,7 +258,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 +710,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 +719,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 +835,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 +847,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,13 +990,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 }: 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))); @@ -1006,8 +1009,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 = quality <= 90 && !grey && !fullChroma; + // (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); @@ -1069,13 +1072,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); @@ -1123,7 +1126,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); @@ -1140,20 +1143,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); @@ -1162,7 +1165,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..c2156b96 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'; @@ -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; @@ -120,21 +120,21 @@ 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 }; } /** 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/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/frame-header.ts b/src/codecs/jxl/frame-header.ts index f72bd1a8..2361d931 100644 --- a/src/codecs/jxl/frame-header.ts +++ b/src/codecs/jxl/frame-header.ts @@ -81,11 +81,12 @@ const DEFAULT_EPF = (): EPF => ({ const DEFAULT_GAB = [0.115169525, 0.061248592, 0.115169525, 0.061248592, 0.115169525, 0.061248592]; -function blendingInfo(r: BitReader, haveExtra: boolean, resets: (mode: number) => boolean, mainMode = -1): BlendingInfo { +function blendingInfo(r: BitReader, numExtra: number, resets: (mode: number) => boolean, mainMode = -1): BlendingInfo { const mode = r.u32(0, 1, 2, [3, 2]); if (mode > 4) throw new Error('JPEG XL: invalid blend mode'); - const usesAlpha = haveExtra && (mode === BLEND_BLEND || mode === BLEND_MULADD); + const usesAlpha = numExtra > 0 && (mode === BLEND_BLEND || mode === BLEND_MULADD); const alphaChannel = usesAlpha ? r.u32(0, 1, 2, [3, 3]) : 0; + if (alphaChannel >= Math.max(1, numExtra)) throw new Error('JPEG XL: invalid blend alpha channel'); const clamp = usesAlpha || mode === BLEND_MUL ? r.bool() : false; const source = resets(mainMode >= 0 ? mainMode : mode) ? 0 : r.bits(2); return { mode, alphaChannel, clamp, source }; @@ -164,8 +165,8 @@ export function frameHeader(r: BitReader, image: ImageHeader): FrameHeader { let blending: BlendingInfo = { mode: BLEND_REPLACE, alphaChannel: 0, clamp: false, source: 0 }; const ecBlending: BlendingInfo[] = []; if (!allDefault && normal) { - blending = blendingInfo(r, numExtra > 0, resets); - for (let i = 0; i < numExtra; i++) ecBlending.push(blendingInfo(r, true, resets, blending.mode)); + blending = blendingInfo(r, numExtra, resets); + for (let i = 0; i < numExtra; i++) ecBlending.push(blendingInfo(r, numExtra, resets, blending.mode)); } else for (let i = 0; i < numExtra; i++) ecBlending.push({ ...blending }); let duration = 0, isLast = type === FRAME_REGULAR, saveAsReference = 0; if (!allDefault && normal) { diff --git a/src/codecs/jxl/frame.ts b/src/codecs/jxl/frame.ts index 5a00ff50..bacff156 100644 --- a/src/codecs/jxl/frame.ts +++ b/src/codecs/jxl/frame.ts @@ -56,7 +56,11 @@ class Sections { private single: BitReader | null = null; constructor(private readonly d: Uint8Array, private readonly t: TOC) { - if (t.offsets.length === 1) this.single = new BitReader(d, t.offsets[0], t.offsets[0] + t.sizes[0]); + if (t.offsets.length === 1) { + // (a frame of one section that the data ends in: its missing bytes would read as zeros) + if (t.offsets[0] + t.sizes[0] > d.length) throw new Error('JPEG XL: truncated data'); + this.single = new BitReader(d, t.offsets[0], t.offsets[0] + t.sizes[0]); + } } /** the reader of section i (LfGlobal, the LF groups, HfGlobal, then the pass groups) */ diff --git a/src/codecs/jxl/headers.ts b/src/codecs/jxl/headers.ts index f7d3bd80..6b1e12a7 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'); @@ -273,7 +273,7 @@ const COMMON_DATA = ['XYZ ', 'desc', 'text', 'mluc', 'para', 'curv', 'sf32', 'gb /** The ICC profile an encoded ICC stream (see readICC) describes, null if the stream is invalid */ export function decodeICC(stream: Uint8Array): Uint8Array | null { - let at = 0; + let at: number; const fail = (): never => { throw new RangeError('invalid ICC stream'); }; 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/patches-enc.ts b/src/codecs/jxl/patches-enc.ts index 926464c2..63ae87cc 100644 --- a/src/codecs/jxl/patches-enc.ts +++ b/src/codecs/jxl/patches-enc.ts @@ -283,7 +283,7 @@ export function findPatches(planes: Float32Array[], stride: number, width: numbe maxH = Math.max(maxH, p.h); } let refW = Math.max(maxW, Math.sqrt(total)), refH = Math.max(maxH, Math.sqrt(total)); - let spots: Array<[number, number]> = [], usedH = 0; + let spots: Array<[number, number]> = [], usedH: number; for (; ;) { refW = Math.floor(refW * 1.05 + 1); refH = Math.floor(refH * 1.05 + 1); 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 75ba16b9..038b158e 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 { +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 932a3913..74b0c3c3 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; } @@ -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); @@ -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) { @@ -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); @@ -498,6 +498,8 @@ interface Parsed { frames: Array<{ control: APNGFrameControl, data: Uint8Array[] }>; /** set if the animation is broken, which makes decoders fall back to the default image */ animationError: boolean; + /** set for an animation that ends before its IEND chunk: the frames that are there are what it has (the last one can be cut) */ + truncated: boolean; } function parse(array: Uint8Array): Parsed { @@ -518,6 +520,7 @@ function parse(array: Uint8Array): Parsed { let current: Parsed['frames'][number] | null = null; let sequence = 0; let animationError = false; + let ended = false, truncated = false; let offset = 8; while (offset + 8 <= array.length) { @@ -585,7 +588,7 @@ function parse(array: Uint8Array): Parsed { else if (type === iCCP) iccp ??= chunk; else if (type === sRGB) srgb = true; else if (type === tEXt || type === zTXt || type === iTXt) text.push({ type, chunk }); - else if (type === IEND) break; + else if (type === IEND) { ended = true; break; } offset = end + 4; } @@ -603,9 +606,15 @@ function parse(array: Uint8Array): Parsed { // more frames than announced are ignored; fewer (or none) make the animation invalid if (animation) { if (frames.length > animation.frames) frames.length = animation.frames; - if (!frames.length || frames.length < animation.frames || frames.some(f => !f.data.length)) animationError = true; + // a file cut short keeps the frames that arrived (the last one may be cut: the decoder checks), like it does + // for GIF; one that ends properly with fewer frames than it announced is invalid + if (!ended) { + truncated = true; + while (frames.length && !frames[frames.length - 1].data.length) frames.pop(); + } + if (!frames.length || (frames.length < animation.frames && !truncated) || frames.some(f => !f.data.length)) animationError = true; } - return { header, palette, transparency, idat, exif, iccp: srgb ? null : iccp, text, animation, frames, animationError }; + return { header, palette, transparency, idat, exif, iccp: srgb ? null : iccp, text, animation, frames, animationError, truncated }; } // the interlace passes of an image ([x start, y start, x step, y step, width, height]) and its inflated size @@ -715,36 +724,51 @@ export function isAnimated(array: Uint8Array): boolean { /** * Decodes an APNG (https://wiki.mozilla.org/APNG_Specification) into its (uncomposited) frames; a plain PNG becomes - * a single frame + * a single frame. The frames are decoded one at a time as they are iterated (once), so compositing them doesn't hold + * all of them * @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: AsyncIterable, 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) { const pixels = await decodeImageData(p, p.idat, width, height); - return { width, height, plays: 0, frames: [{ x: 0, y: 0, width, height, duration: 0, dispose: 0, blend: 0, pixels }], icc: await profile(p.iccp), orientation }; + const still = async function* (): AsyncGenerator { + yield { x: 0, y: 0, width, height, duration: 0, dispose: 0, blend: 0, pixels }; + }; + return { width, height, plays: 0, frames: still(), icc: await profile(p.iccp), orientation }; } - const frames: APNGFrame[] = []; - for (const { control: c, data } of p.frames) { + for (const { control: c } of p.frames) if (!c.width || !c.height || c.x + c.width > width || c.y + c.height > height) throw new Error('PNG: invalid APNG frame region'); - frames.push({ - x: c.x, y: c.y, width: c.width, height: c.height, duration: c.delay, - // DISPOSE_OP_PREVIOUS on the first frame means background - dispose: !frames.length && c.dispose === 2 ? 1 : c.dispose, - blend: c.blend, - pixels: await decodeImageData(p, data, c.width, c.height) - }); - if (onlyFirstFrame) break; + async function* frames(): AsyncGenerator { + let count = 0; + for (const { control: c, data } of p.frames) { + let pixels: Uint8Array; + try { + pixels = await decodeImageData(p, data, c.width, c.height); + } catch (error) { + // (the last frame of a file that is cut off can be incomplete: the ones before it are what the file has) + if (p.truncated && count) return; + throw error; + } + yield { + x: c.x, y: c.y, width: c.width, height: c.height, duration: c.delay, + // DISPOSE_OP_PREVIOUS on the first frame means background + dispose: !count++ && c.dispose === 2 ? 1 : c.dispose, + blend: c.blend, + pixels + }; + if (firstFrameOnly) return; + } } - return { width, height, plays: p.animation.plays, frames, icc: await profile(p.iccp), orientation }; + return { width, height, plays: p.animation.plays, frames: frames(), icc: await profile(p.iccp), orientation }; } /** @@ -752,10 +776,15 @@ export async function decodeAnimation(array: Uint8Array, onlyFirstFrame = false, * blend_op section, https://wiki.mozilla.org/APNG_Specification) */ export function compositeAnimation({ width, height, frames }: { width: number, height: number, frames: APNGFrame[] }): Array<{ pixels: Uint8Array, duration: number }> { + const step = compositor(width, height); + return frames.map(f => ({ pixels: step(f), duration: f.duration })); +} + +/** {@link compositeAnimation} one frame at a time: each call composites the next frame and returns the canvas shown */ +export function compositor(width: number, height: number): (f: APNGFrame) => Uint8Array { const canvas = new Uint8Array(width * height * 4); - const output = []; - for (const f of frames) { + return f => { const previous = f.dispose === 2 ? canvas.slice() : null; for (let y = 0; y < f.height; y++) { @@ -782,14 +811,14 @@ export function compositeAnimation({ width, height, frames }: { width: number, h } } - output.push({ pixels: canvas.slice(), duration: f.duration }); + const shown = canvas.slice(); if (f.dispose === 1) for (let y = 0; y < f.height; y++) canvas.fill(0, ((f.y + y) * width + f.x) * 4, ((f.y + y) * width + f.x + f.width) * 4); else if (previous) canvas.set(previous); - } - return output; + return shown; + }; } const RAW_EXIF = /^Raw profile type (exif|APP1)$/; @@ -846,7 +875,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 +897,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 +920,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/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/quantize.ts b/src/codecs/quantize.ts index 2b0dfae1..9920f182 100644 --- a/src/codecs/quantize.ts +++ b/src/codecs/quantize.ts @@ -1,22 +1,43 @@ +import type { DitherMethod } from '../image.ts'; +import { oneOf, finite } from '../util/options.ts'; // 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; } +// 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 + * 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 = finite(colors, 'colors'); + // eslint-disable-next-line no-restricted-syntax -- the palette size is only checked here + if (!Number.isInteger(count) || count < 2 || count > 256) throw new RangeError(`colors must be an integer from 2 to 256, not ${colors}`); + } + oneOf(dither, DITHER_METHODS, 'dither'); + const strength = finite(ditherStrength, 'ditherStrength'); + // eslint-disable-next-line no-restricted-syntax -- the dither strength is only checked here + 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; @@ -33,16 +54,30 @@ interface Clusters { } /** - * Finds the color of the largest cluster among the opaque pixels that pass the filter - * @return packed RGB, or null if no pixel qualifies + * Finds the dominant color among the pixels that are at least half opaque and pass the filter: the colors are counted + * in a histogram of 16 levels per channel (4096 bins, like sharp's), and the average of the pixels in the fullest bin + * is the result, so it is a color the image has (a cluster's average can lie between two hues) + * @return RGB, or null if no pixel qualifies */ -export function dominantColor(pixels: Uint8Array, include: (r: number, g: number, b: number) => boolean, clusters = 8): [number, number, number] | null { - const { palette, weights } = medianCut(pixels, clusters, 4, include); - if (!weights.length) return null; +export function dominantColor(pixels: Uint8Array, include: (r: number, g: number, b: number) => boolean): [number, number, number] | null { + const counts = new Uint32Array(4096), sums = new Float64Array(4096 * 3); + let found = false; + for (let i = 0; i < pixels.length; i += 4) { + if (pixels[i + 3] < 128) continue; + const r = pixels[i], g = pixels[i + 1], b = pixels[i + 2]; + if (!include(r, g, b)) continue; + const bin = (r >> 4) << 8 | (g >> 4) << 4 | b >> 4; + counts[bin]++; + sums[bin * 3] += r; + sums[bin * 3 + 1] += g; + sums[bin * 3 + 2] += b; + found = true; + } + if (!found) return null; let best = 0; - for (let i = 1; i < weights.length; i++) if (weights[i] > weights[best]) best = i; - return [Math.round(palette[best * 3]), Math.round(palette[best * 3 + 1]), Math.round(palette[best * 3 + 2])]; + for (let bin = 1; bin < 4096; bin++) if (counts[bin] > counts[best]) best = bin; + return [Math.round(sums[best * 3] / counts[best]), Math.round(sums[best * 3 + 1] / counts[best]), Math.round(sums[best * 3 + 2] / counts[best])]; } interface Points { @@ -61,7 +96,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 +109,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; @@ -162,10 +199,10 @@ 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 { +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); @@ -444,15 +481,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/codecs/tga.ts b/src/codecs/tga.ts index 16462973..90103239 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]; @@ -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/tiff.ts b/src/codecs/tiff.ts index 825e05bf..473b640c 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}} */ @@ -142,6 +142,7 @@ export async function decode(data: Uint8Array, maxPixels = MAX_PIXELS): Promise< const samplesPerPixel = value(TAGS.SamplesPerPixel, 1); const bitsPerSample = values(TAGS.BitsPerSample, [1]); const bps = bitsPerSample[0]; + if (typeof bps !== 'number') throw new Error('TIFF: invalid BitsPerSample'); if (bitsPerSample.some(b => b !== bps)) throw new Error('TIFF: mixed bit depths are not supported'); const compression = value(TAGS.Compression, 1); @@ -187,6 +188,8 @@ export async function decode(data: Uint8Array, maxPixels = MAX_PIXELS): Promise< // memory than the pixels checkPixels(segmentWidth, segmentHeight, Math.max(4 * width * height, 1 << 20)); checkBytes(Math.ceil(width * samplesPerPixel * bps / 8) * height, maxPixels); + // (only the first four samples show: more are multispectral bands, whose count would only multiply the memory) + if (samplesPerPixel > 32) throw new Error('TIFF: unsupported number of samples per pixel'); const pixels = new Uint8Array(width * height * 4); // JPEG compressed segments decode straight to RGBA @@ -205,7 +208,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); } @@ -221,8 +224,11 @@ export async function decode(data: Uint8Array, maxPixels = MAX_PIXELS): Promise< if (subsampled) { if (tiled || planar || bps !== 8) throw new Error('TIFF: subsampled YCbCr is only supported for 8-bit strips'); const [h, v] = subsampling; + // (the factors the specification allows: a bigger one would make the units larger than the image) + if (![1, 2, 4].includes(h) || ![1, 2, 4].includes(v)) throw new Error('TIFF: unsupported YCbCr subsampling'); const unitsPerRow = Math.ceil(width / h); const unitRowBytes = unitsPerRow * (h * v + 2); + checkBytes(Math.ceil(height / v) * unitRowBytes, maxPixels); const units = new Uint8Array(Math.ceil(height / v) * unitRowBytes); let written = 0; 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 d7cdc9a1..8d3bb69a 100644 --- a/src/codecs/webp.ts +++ b/src/codecs/webp.ts @@ -40,7 +40,8 @@ export interface WebPAnimation { loopCount: number; /** ARGB background color hint (decoders, like browsers, are free to ignore it) */ background: number; - frames: WebPFrame[]; + /** decoded one at a time as they are iterated (once), so compositing doesn't hold all of them */ + frames: Iterable; /** the embedded ICC profile */ icc?: Uint8Array; /** Exif orientation (1-8) */ @@ -50,6 +51,8 @@ export interface WebPAnimation { interface Chunk { type: number; data: Uint8Array; + /** the chunk is cut off: the data ends before its size says */ + cut?: boolean; } function chunks(data: Uint8Array, start: number, end: number): Chunk[] { @@ -61,7 +64,7 @@ function chunks(data: Uint8Array, start: number, end: number): Chunk[] { const size = view.getUint32(offset + 4, true); const dataStart = offset + 8; // tolerate a truncated last chunk, the bitstream decoders report missing data - out.push({ type, data: data.subarray(dataStart, Math.min(end, dataStart + size)) }); + out.push({ type, data: data.subarray(dataStart, Math.min(end, dataStart + size)), cut: dataStart + size > end }); offset = dataStart + size + (size & 1); } return out; @@ -181,9 +184,10 @@ 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 + * Decodes an animated WebP into its frames (not composited, decoded one at a time as they are iterated); 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,21 +198,24 @@ 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[] = []; + const anim = list.find(c => c.type === ANIM && c.data.length >= 6); + if (anim) { + const d = anim.data; + // stored as BGRA + background = (d[3] << 24 | d[2] << 16 | d[1] << 8 | d[0]) >>> 0; + loopCount = d[4] | d[5] << 8; + } + // (a frame whose chunk is cut off is incomplete: the whole ones before it are the animation) + const anmf = list.filter(c => c.type === ANMF && !c.cut); + if (!anmf.length && list.some(c => c.type === ANMF)) throw new Error('WebP: unexpected end of data'); + for (const { data } of anmf) if (data.length < 16) throw new Error('WebP: invalid ANMF chunk'); - for (const chunk of list) { - if (chunk.type === ANIM && chunk.data.length >= 6) { - const d = chunk.data; - // stored as BGRA - background = (d[3] << 24 | d[2] << 16 | d[1] << 8 | d[0]) >>> 0; - loopCount = d[4] | d[5] << 8; - } else if (chunk.type === ANMF) { - const d = chunk.data; - if (d.length < 16) throw new Error('WebP: invalid ANMF chunk'); + function* frames(): Generator { + for (const { data: d } of anmf) { const frame = decodeBitstream(chunks(d, 16, d.length), maxPixels); - frames.push({ + yield { x: u24(d, 0) * 2, y: u24(d, 3) * 2, width: frame.width, @@ -217,13 +224,12 @@ export function decodeAnimation(data: Uint8Array, onlyFirstFrame = false, maxPix blend: (d[15] & 2) === 0, dispose: (d[15] & 1) === 1, pixels: frame.pixels - }); - if (onlyFirstFrame) break; + }; + if (firstFrameOnly) return; } } - if (!frames.length) throw new Error('WebP: animation without frames'); - return { width, height, loopCount, background, frames, icc: list.find(c => c.type === ICCP)?.data, orientation: orientationOf(list) }; + return { width, height, loopCount, background, frames: frames(), icc: list.find(c => c.type === ICCP)?.data, orientation: orientationOf(list) }; } // the Exif orientation of a WebP's chunks, 1 without one @@ -234,7 +240,7 @@ function orientationOf(list: Chunk[]): number { /** * Composites animation frames onto the canvas like browsers do (transparent background, the background color hint - * is ignored as the spec allows) + * is ignored as the spec allows). Each frame is let go once it is composited */ export function composite({ width, height, frames }: WebPAnimation): Array<{ pixels: Uint8Array, duration: number }> { const canvas = new Uint8Array(width * height * 4); @@ -302,7 +308,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 +346,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 +398,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 +455,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 +493,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/codecs/zlib.ts b/src/codecs/zlib.ts index b957e6b3..6e8b9fb2 100644 --- a/src/codecs/zlib.ts +++ b/src/codecs/zlib.ts @@ -107,7 +107,7 @@ function inflate(dat: Uint8Array, buf: Uint8Array): Uint8Array { if (!sl) return buf.subarray(0, 0); const tbts = sl * 8; - let final = 0, pos = 0, bt = 0; + let final: number, pos = 0, bt = 0; do { let lm: Uint16Array, dm: Uint16Array, lbt: number, dbt: number; diff --git a/src/colors.ts b/src/colors.ts index d6b29fca..59aac431 100644 --- a/src/colors.ts +++ b/src/colors.ts @@ -1,4 +1,4 @@ -import { finite, color as checkColor } from './util/options.ts'; +import { finite, checkNumber, color as checkColor } from './util/options.ts'; import type { Color } from './image.ts'; /** Clamps and rounds a channel value to 0..255 */ @@ -62,21 +62,30 @@ export function rgbaToHSLA(r: number, g: number, b: number, a: number): [h: numb return [h, s, l, a / 255]; } +/** A position along a gradient: a number (the infinities are fine, they reach the ends), not NaN */ +function checkPosition(position: number): void { + checkNumber(position, 'gradient position'); + if (Number.isNaN(position)) throw new RangeError(`Invalid gradient position: ${position}`); +} + function gradientBetween(startColor: Color, endColor: Color): (position: number) => Color { - const sr = startColor >>> 24; - const sg = startColor >> 16 & 0xff; - const sb = startColor >> 8 & 0xff; - const sa = startColor & 0xff; - const er = (endColor >>> 24) - sr; - const eg = (endColor >> 16 & 0xff) - sg; - const eb = (endColor >> 8 & 0xff) - sb; - const ea = (endColor & 0xff) - sa; + const sr = startColor >>> 24, sg = startColor >> 16 & 0xff, sb = startColor >> 8 & 0xff, sa = startColor & 0xff; + const er = (endColor >>> 24) - sr, eg = (endColor >> 16 & 0xff) - sg, eb = (endColor >> 8 & 0xff) - sb, ea = (endColor & 0xff) - sa; + + // opaque colors blend directly + if (sa === 255 && ea === 0) return position => { + const r = sr + position * er, g = sg + position * eg, b = sb + position * eb; + return (((r & 0xff) << 24) | ((g & 0xff) << 16) | ((b & 0xff) << 8) | 0xff) >>> 0; + }; + // with transparency the colors blend premultiplied (like CSS does), so a color doesn't leak from what is invisible + const pr = sr * sa / 255, pg = sg * sa / 255, pb = sb * sa / 255; + const dr = (er + sr) * (ea + sa) / 255 - pr, dg = (eg + sg) * (ea + sa) / 255 - pg, db = (eb + sb) * (ea + sa) / 255 - pb; return position => { - const r = sr + position * er; - const g = sg + position * eg; - const b = sb + position * eb; const a = sa + position * ea; + if (a <= 0) return 0; + const k = 255 / a; + const r = Math.min(255, (pr + position * dr) * k + 1e-9), g = Math.min(255, (pg + position * dg) * k + 1e-9), b = Math.min(255, (pb + position * db) * k + 1e-9); return (((r & 0xff) << 24) | ((g & 0xff) << 16) | ((b & 0xff) << 8) | (a & 0xff)) >>> 0; }; } @@ -117,14 +126,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 +158,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 @@ -170,7 +179,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]) */ @@ -182,30 +191,43 @@ 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]; } /** * 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])); if (positions.length === 0) throw new RangeError('Invalid gradient point count'); else if (positions.length === 1) { - return () => values[0]; + return position => { + checkPosition(position); + return values[0]; + }; } else if (positions.length === 2) { const gradient = gradientBetween(values[0], values[1]); return position => { + checkPosition(position); if (position <= positions[0]) return values[0]; if (position >= positions[1]) return values[1]; return gradient((position - positions[0]) / (positions[1] - positions[0])); @@ -221,6 +243,8 @@ export class Colors { if (minPos === undefined) continue; const maxPos = positions[i]; + // (two stops at the same position: a hard edge, nothing in between) + if (maxPos === minPos) continue; let minVal = values[i - 1]; if (minVal === undefined) minVal = values[i]; @@ -231,6 +255,12 @@ export class Colors { gradients.push({min: minPos, max: maxPos, gradient}); } + // (every stop at the same position: a hard edge between the first and the last color) + if (!gradients.length) return position => { + checkPosition(position); + return position <= minDef ? values[0] : values[values.length - 1]; + }; + return position => { if (position <= minDef) return gradients[0].gradient(0); if (position >= maxDef) return gradients[gradients.length - 1].gradient(1); diff --git a/src/frame.ts b/src/frame.ts index 9339437a..05a6fc8e 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -1,63 +1,62 @@ -import { Image, ResizeMode, ResizeOptions, ResizeToOptions, Color, TrimOptions, resizeArguments } from './image.ts'; -import { isOptions, finite } from './util/options.ts'; +import { Image, ResizeOptions, ResizeToOptions, TrimOptions, RotateOptions, TransformOptions, AffineTransform, ShadowOptions, OutlineOptions } from './image.ts'; +import { checkOptions, finite, oneOf } 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 + * 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 FrameDisposalModeId = 0 | 1 | 2 | 3; +export type FrameDisposalMode = 'any' | 'keep' | 'previous' | 'background'; -// (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']; +const DISPOSAL_MODES: readonly FrameDisposalMode[] = ['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} */ +/** Options of {@link Frame} */ export interface FrameOptions { - /** The frame's duration in milliseconds. Default: `100` */ + /** + * The frame's duration in milliseconds + * @defaultValue `100` + */ duration?: number; - /** The frame's offset on the x-axis. Default: `0` */ + /** + * The frame's offset on the x-axis + * @defaultValue `0` + */ xOffset?: number; - /** The frame's offset on the y-axis. Default: `0` */ + /** + * The frame's offset on the y-axis + * @defaultValue `0` + */ yOffset?: number; - /** The frame's disposal mode, see {@link Frame.disposalMode}. Default: `'keep'` */ - disposalMode?: FrameDisposalModeName | FrameDisposalModeId; + /** + * The frame's disposal mode, see {@link Frame.disposalMode} + * @defaultValue `'keep'` + */ + disposalMode?: FrameDisposalMode; } /** - * 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 + * @remarks + * 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 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; +export class Frame { + /** @internal @ignore */ + private __image__: Image; + /** @internal @ignore */ + private __duration__ = 100; + /** @internal @ignore */ + private __x__ = 0; + /** @internal @ignore */ + private __y__ = 0; /** * @internal * @ignore @@ -65,79 +64,105 @@ export class Frame extends Image { private __disposalMode__: DisposalId = 1; /** - * @deprecated Use {@link FrameDisposalModes.ANY}, `Frame.DISPOSAL_*` will be removed in 4.0 - * @group Deprecated + * @internal + * @ignore */ - static readonly DISPOSAL_ANY: typeof FrameDisposalModes.ANY = FrameDisposalModes.ANY; + static __convert_disposal_mode__(mode: FrameDisposalMode): DisposalId { + return DISPOSAL_MODES.indexOf(oneOf(mode, DISPOSAL_MODES, 'disposal mode')) as DisposalId; + } /** - * @deprecated Use {@link FrameDisposalModes.KEEP}, `Frame.DISPOSAL_*` will be removed in 4.0 - * @group Deprecated + * 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} */ - static readonly DISPOSAL_KEEP: typeof FrameDisposalModes.KEEP = FrameDisposalModes.KEEP; + 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; + 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__; + } /** - * @deprecated Use {@link FrameDisposalModes.PREVIOUS}, `Frame.DISPOSAL_*` will be removed in 4.0 - * @group Deprecated + * Replaces the frame's pixels + * @param image The new image (used as it is, not copied) */ - static readonly DISPOSAL_PREVIOUS: typeof FrameDisposalModes.PREVIOUS = FrameDisposalModes.PREVIOUS; + 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; + } /** - * @deprecated Use {@link FrameDisposalModes.BACKGROUND}, `Frame.DISPOSAL_*` will be removed in 4.0 - * @group Deprecated + * 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 */ - static readonly DISPOSAL_BACKGROUND: typeof FrameDisposalModes.BACKGROUND = FrameDisposalModes.BACKGROUND; + get duration(): number { + return this.__duration__; + } /** - * @internal - * @ignore + * Sets the duration + * @param duration Milliseconds, a finite number from 0 */ - static __convert_disposal_mode__(mode: FrameDisposalModeName | FrameDisposalModeId): DisposalId { - const id = typeof mode === 'string' ? DISPOSAL_MODES.indexOf(mode) : mode; - if (!(id >= 0 && id <= 3)) - throw new RangeError('Invalid disposal mode'); - - return id as DisposalId; + set duration(duration: number) { + const value = finite(duration, 'frame duration'); + if (value < 0) throw new RangeError('Invalid frame duration'); + this.__duration__ = value; } /** - * Creates a new, blank frame - * @param width The width in pixels - * @param height The height in pixels - * @param options See {@link FrameOptions} + * The frame's horizontal offset in the animation: an integer, values with a fraction are truncated like image sizes + * (a value that isn't a finite number throws a RangeError) */ - constructor(width: number, height: number, options?: FrameOptions); + get xOffset(): number { + return this.__x__; + } + /** - * 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 + * Sets the horizontal offset + * @param x The offset in pixels */ - 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) { - 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) - throw new RangeError('Invalid frame duration'); - finite(x, 'x offset'); - finite(y, 'y offset'); + set xOffset(x: number) { + // (|| 0: no negative zero) + this.__x__ = Math.trunc(finite(x, 'x offset')) || 0; + } - super(width, height); - this.duration = duration; - this.xOffset = x; - this.yOffset = y; - this.disposalMode = disposal; + /** The frame's vertical offset in the animation: an integer, like {@link Frame.xOffset} */ + get yOffset(): number { + return this.__y__; } /** - * 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 + * Sets the vertical offset + * @param y The offset in pixels */ - get disposalMode(): FrameDisposalModeName { + set yOffset(y: number) { + this.__y__ = Math.trunc(finite(y, 'y offset')) || 0; + } + + /** The frame's disposal mode, see {@link FrameDisposalMode} */ + get disposalMode(): FrameDisposalMode { return DISPOSAL_MODES[this.__disposalMode__]; } @@ -152,131 +177,124 @@ 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: FrameDisposalMode) { this.__disposalMode__ = Frame.__convert_disposal_mode__(disposalMode); } /** 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; - /** - * 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 | FrameDisposalModeId): Frame; - static from(image: Image, durationOrOptions?: number | FrameOptions, xOffset?: number, yOffset?: number, disposalMode?: FrameDisposalModeName | FrameDisposalModeId): Frame { - if (!(image instanceof Image)) - throw new TypeError('Invalid image passed'); - - const options = isOptions(durationOrOptions) ? durationOrOptions : { duration: durationOrOptions, xOffset, yOffset, disposalMode }; - 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 + * Runs a change of the image, moving the offsets the way the image reports its pixels moved + * @internal + * @ignore */ - override clone(): Frame { - return Frame.from(this, { duration: this.duration, xOffset: this.xOffset, yOffset: this.yOffset, disposalMode: this.disposalMode }); + 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; } /** - * @internal - * @ignore + * 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} */ - protected override __room__(): { left: number, top: number } { - return { left: this.xOffset, top: this.yOffset }; + 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 { + return this.__tracked__(image => typeof widthOrOptions === 'number' + ? image.resize(widthOrOptions, heightArgument as number, optionsArgument) + : image.resize(widthOrOptions)); } /** - * @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 __grown__(left: number, top: number): void { - this.xOffset -= left; - this.yOffset -= top; + scale(factor: number, options?: ResizeOptions): this { + return this.__tracked__(image => image.scale(factor, options)); } /** - * Removes borders of one color, moving the frame's offsets so the remaining pixels stay in place - * @param options See {@link TrimOptions} + * 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 */ - override trim(options?: TrimOptions): this; + crop(x: number, y: number, width: number, height: number): this { + return this.__tracked__(image => image.crop(x, y, width, height)); + } + /** - * 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 + * Removes borders of one color, moving the offsets so the remaining pixels stay where they are in the animation * @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); - if (!bounds) return this; - this.xOffset += bounds[0]; - this.yOffset += bounds[1]; - return this.__apply__(this.__crop__(...bounds)); + trim(options?: TrimOptions): this { + return this.__tracked__(image => image.trim(options)); } /** - * 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} + * 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} */ - override resize(width: number, height: number, options?: ResizeOptions): this; + rotate(angle: number, options?: RotateOptions): this { + return this.__tracked__(image => image.rotate(angle, options)); + } + /** - * 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 + * 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} */ - override resize(width: number, height: number, mode?: ResizeMode): this; + transform(matrix: AffineTransform, options?: TransformOptions): this { + return this.__tracked__(image => image.transform(matrix, options)); + } + /** - * 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} + * 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} */ - override resize(options: ResizeToOptions): this; - override resize(widthOrOptions: number | ResizeToOptions, heightArgument?: number, modeArgument?: ResizeMode | ResizeOptions): this { - const [width, height, modeOrOptions] = resizeArguments(widthOrOptions, heightArgument, modeArgument); - const originalWidth = this.width; - const originalHeight = this.height; - - super.resize(width, height, modeOrOptions as ResizeMode); - - this.xOffset = Math.round(this.xOffset * this.width / originalWidth); - this.yOffset = Math.round(this.yOffset * this.height / originalHeight); - - return this; + dropShadow(options?: ShadowOptions): this { + return this.__tracked__(image => image.dropShadow(options)); } /** - * Scales the offsets like {@link Frame.resize} for a resize to the given size (that only produces a part of it) - * @internal - * @ignore + * 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} */ - 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); + outline(options?: OutlineOptions): this { + return this.__tracked__(image => image.outline(options)); } } diff --git a/src/gainmap.ts b/src/gainmap.ts index fce0faf8..89e14537 100644 --- a/src/gainmap.ts +++ b/src/gainmap.ts @@ -1,89 +1,198 @@ -import { Image } from './image.ts'; +import { Image, alignedPixels } from './image.ts'; +import type { DecodeOptions } from './image.ts'; +import { MAX_PIXELS, decoding } from './util/limits.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, pixelLimit, quality as checkQuality } from './util/options.ts'; -/** Options of {@link GainMap.encode} */ +/** Options of {@link GainMap.encodeJPEG} */ export interface GainMapEncodeOptions { - /** The JPEG quality (1-100). Default: `90` */ + /** + * The JPEG quality (1-100) + * @defaultValue `90` + */ 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). 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[]; - /** 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` */ - offsetSDR?: number | number[]; - /** subtracted from the HDR result. Default: `1/64` */ - offsetHDR?: number | number[]; - /** log2 of the display headroom from which on the map starts to apply. Default: `0` */ + /** + * Extra brightness at the map's darkest value (0); `-1` means half as bright + * @defaultValue `0` (no change) + */ + min?: GainMapChannelValues; + /** + * Extra brightness at the map's brightest value (255); `2` means four times as bright + * @defaultValue `2` + */ + max?: GainMapChannelValues; + /** + * How quickly the brightness changes between `min` and `max`; `1` changes it evenly + * @defaultValue `1` + */ + gamma?: GainMapChannelValues; + /** + * Small brightness offset added before applying the boost, to keep black from being boosted indefinitely. + * @defaultValue `1/64` + */ + offsetSDR?: GainMapChannelValues; + /** + * Small brightness offset subtracted from the result + * @defaultValue `1/64` + */ + offsetHDR?: GainMapChannelValues; + /** + * How bright a display must be before the map starts to apply; `0` is standard-screen brightness + * @defaultValue `0` + */ hdrCapacityMin?: number; - /** log2 of the display headroom at which it applies fully. Default: `gainMapMax`'s largest value */ + /** + * How bright a display must be for the map to apply fully + * @defaultValue 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) + * @defaultValue `true` + */ useBaseColorSpace?: boolean; } -const perChannel = (value: number | number[], name: string): number[] => { - const values = typeof value === 'number' ? [value, value, value] : Array.from(value); +const perChannel = (value: GainMapChannelValues, name: string): number[] => { + const given: unknown = value; + if (typeof given !== 'number' && !Array.isArray(given)) throw new TypeError(`${name} must be a number or an array of 3 numbers, not ${given === null ? 'null' : typeof given}`); + const values: number[] = 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`); return values.map(v => finite(v, name)); }; /** - * 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 + * @remarks + * {@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. * - * {@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))` + * 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(): 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(): 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(): 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] + */ + set gamma(value: GainMapChannelValues) { + const gamma = perChannel(value, 'gamma'); + // eslint-disable-next-line no-restricted-syntax -- a rule of the gain map's values, not a shared option + 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(): 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(): 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] + */ + 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__; } + /** + * 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) { + // eslint-disable-next-line no-restricted-syntax -- the only boolean option that is validated + if (typeof value !== 'boolean') throw new TypeError('useBaseColorSpace must be a boolean'); + this.__useBaseColorSpace__ = value; + } /** its pixels */ private __pixels__: Image | null = null; /** * 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} */ constructor(image: Image, options: GainMapOptions = {}) { + 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 === undefined ? Math.max(...this.__max__) : options.hdrCapacityMax; + this.useBaseColorSpace = useBaseColorSpace; this.__pixels__ = image; this.__check__(); } @@ -92,10 +201,10 @@ export class GainMap { * @internal from a decoded file: the map's JPEG, decoded now (a damaged one throws here, not in a later * operation); `apple`: an Apple gain map's headroom, its values converted to the parameters' scale */ - static __from_jpeg__(map: Uint8Array, params: codec.Params, apple = 0): GainMap { - const { width, height, pixels } = jpeg.decode(map); + static __from_jpeg__(map: Uint8Array, params: codec.Params, apple = 0, maxPixels = MAX_PIXELS): GainMap { + const { width, height, pixels } = jpeg.decode(map, { maxPixels }); const image = new Image(width, height); - image.bitmap = pixels; + image.bitmap = alignedPixels(pixels); if (apple) { const table = new Uint8Array(256); for (let v = 0; v < 256; v++) table[v] = Math.round(255 * codec.appleValue(v / 255, apple)); @@ -109,12 +218,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); @@ -126,36 +235,33 @@ 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++) + // eslint-disable-next-line no-restricted-syntax -- a relation between two values of the gain map + if (this.__max__[c] < this.__min__[c]) throw new RangeError(`The gain map's max must be at least its min (${this.__max__[c]} < ${this.__min__[c]}; the values are checked together when the map is cloned or encoded)`); + if (this.__capacityMax__ <= this.__capacityMin__) + // eslint-disable-next-line no-restricted-syntax -- a relation between two values of the gain map + throw new RangeError(`The gain map's hdrCapacityMax (by default the largest max) must be above its hdrCapacityMin (${this.__capacityMax__} <= ${this.__capacityMin__})`); } /** The map's pixels. Changes to them are written when the image is encoded */ @@ -182,7 +288,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; @@ -206,16 +312,16 @@ 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 */ __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]); }); } @@ -268,18 +374,16 @@ export class GainMap { * 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 options See {@link GainMapEncodeOptions} + * @see {@link Image.encodeJPEG} for the image with its gain map embedded */ - 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; - if (quality !== undefined) quality = Math.max(1, checkQuality(quality)); + encodeJPEG(options?: GainMapEncodeOptions): Promise>; + async encodeJPEG(options?: GainMapEncodeOptions): Promise> { + checkOptions(options, 'gainMap.encodeJPEG({ quality: 80 })'); + const quality = options?.quality; + if (quality !== undefined) { + checkQuality(quality); + if (quality < 1) throw new RangeError(`quality has to be between 1 and 100, not ${quality}`); + } return codec.mapWithMetadata(this.__body__(quality), this.__params__()); } @@ -287,13 +391,21 @@ export class GainMap { * Decodes a gain map JPEG with its parameters (hdrgm XMP or ISO 21496-1), like the second image in an Ultra HDR * JPEG, or the gain map of an Ultra HDR JPEG (or an iPhone's JPEG: its values converted to these parameters) * @param data The JPEG + * @param options The `maxPixels` limit, like {@link DecodeOptions.maxPixels} + * @throws {@link LimitError} if the map has more than `options.maxPixels` pixels + * @throws `Error` if the data has no gain map (or no parameters for it), or is damaged */ - static async decode(data: BinaryData): Promise { + static async decode(data: BinaryData, options: Pick = {}): Promise { + checkOptions(options, 'GainMap.decode(data, { maxPixels: 1e8 })'); + const { maxPixels = MAX_PIXELS } = options; + pixelLimit(maxPixels); const bytes = view(data); - const found = codec.find(bytes); - if (found) return GainMap.__from_jpeg__(found.map, found.params, found.apple); - const params = codec.readParams(bytes); - if (!params) throw new Error('No gain map (or no gain map parameters) found'); - return GainMap.__from_jpeg__(bytes, params); + return decoding(() => { + const found = codec.find(bytes); + if (found) return GainMap.__from_jpeg__(found.map, found.params, found.apple, maxPixels); + const params = codec.readParams(bytes); + if (!params) throw new Error('No gain map (or no gain map parameters) found'); + return GainMap.__from_jpeg__(bytes, params, 0, maxPixels); + }); } } diff --git a/src/gif.ts b/src/gif.ts deleted file mode 100644 index 5eaeaab7..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, loopCountOrOptions as number); - } - - /** 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(qualityOrOptions as number); - } - - /** - * 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/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 0c2a7f4b..0ed11a74 100644 --- a/src/image.ts +++ b/src/image.ts @@ -15,9 +15,6 @@ import { availableThreads } from './util/threads.ts'; // (worker threads: the processors available, at most 8) const defaultThreads = (): number => Math.max(1, Math.min(availableThreads(), 8)); -const checkThreads = (threads: number): void => { - if (!Number.isInteger(threads) || threads < 1) throw new RangeError(`threads must be a positive integer, not ${threads}`); -}; import * as jxl from './codecs/jxl.ts'; import type { ToneMapping } from './codecs/hdr.ts'; import * as quantize from './codecs/quantize.ts'; @@ -26,19 +23,19 @@ import * as svgRenderer from './render/svg.ts'; 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 { textLayout, TextLayoutOptions } from './text-layout.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 { rotateBilinear } from './util/rotate.ts'; import { gaussianBlur, boxBlurRGBA, premultiply, unpremultiply } from './util/blur.ts'; -import { Colors, wrapHue, hue2rgb, rgbaToHSLA } 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 { Colors, wrapHue, hue2rgb } from './colors.ts'; +import { isOptions, checkOptions, finite, nonNegative, positive, unitInterval, oneOf, pixelLimit, checkThreads, gainMapScale, color as checkColor, quality as checkQuality, effort as checkEffort, compressionLevel } from './util/options.ts'; +import { MAX_PIXELS, LimitError, decoding, checkGrowth } from './util/limits.ts'; import { GainMap } from './gainmap.ts'; import * as gain from './util/gain.ts'; import { multiply, invert } from './util/matrix.ts'; import { sampleAffine } from './util/affine.ts'; +import { rotateBilinear } from './util/rotate.ts'; import * as gainMapCodec from './codecs/gainmap.ts'; /** A color as a 32-bit RGBA integer (`0xRRGGBBAA`) */ @@ -48,68 +45,16 @@ export type Color = number; 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'; - -/** - * 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; - -/** - * 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'; - - /** - * 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() {} -} - -/** 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() {} -} +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' @@ -117,154 +62,248 @@ 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 + * @defaultValue `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 + * @defaultValue `0` + */ y?: number; - /** Opacity of the source (0..1). Default: `1` */ + /** + * Opacity of the source (0..1) + * @defaultValue `1` + */ opacity?: number; - /** How the colors are mixed. Default: `'normal'` */ + /** + * How the colors are mixed + * @defaultValue `'normal'` + */ 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; -} - -/** Options of {@link Image.scale}, {@link Image.resize}, {@link Image.contain}, {@link Image.fit} 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 + * @defaultValue `'fill'` + */ + fit?: 'fill' | 'inside'; + /** Only with `fit: 'contain'` */ + background?: never; + } + | { + /** 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 + * @defaultValue `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'; + /** Only with `fit: 'contain'` */ + background?: never; + } +); + +type ResizeRuntimeOptions = ResizeOptions & { background?: Color }; + +/** Options of {@link Image.scale} and {@link Image.resize} */ export interface ResizeOptions { - /** The resizing mode. Default: {@link ResizeModes.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}, - * down to about a third of the size. Default: `true` + * The resizing mode + * @defaultValue `'bilinear'` */ - 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; + mode?: ResizeMode; } /** Options of {@link Image.sharpen} */ export interface SharpenOptions { - /** How strongly to sharpen (0 does nothing). Default: `1` */ + /** + * How strongly to sharpen (0 does nothing) + * @defaultValue `1` + */ amount?: number; - /** The radius of the blur the image is compared with (larger values sharpen coarser details). Default: `1` */ + /** + * The radius of the blur the image is compared with (larger values sharpen coarser details) + * @defaultValue `1` + */ radius?: number; } -// the dithering methods of Image.quantize() -const DITHER_METHODS = ['none', 'floyd-steinberg', 'atkinson', 'ordered'] as const; +/** + * 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 { +/** How colors are dithered when they are reduced to a palette: options of {@link Image.quantize} and the GIF encoders */ +export interface DitherOptions { /** - * 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 + * How to spread small color changes across nearby pixels to make gradients look smoother with few colors, see + * {@link DitherMethod} + * @defaultValue `'floyd-steinberg'` */ - colors?: number; + dither?: DitherMethod; /** - * 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 + * How strongly to apply dithering: `0` disables it (like `'none'`); `1` applies it fully + * @defaultValue `0.85` */ - palette?: Color[]; + ditherStrength?: number; +} + +/** + * Options of {@link Image.quantize}: either `colors` or `palette` (TypeScript reports a call with both), and the + * dithering + */ +export type QuantizeOptions = DitherOptions & ({ /** - * 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 - * regular 4x4 pattern of thresholds, no error spreading). Rows alternate direction for the error diffusions. - * Default: `'none'` + * 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. + * @defaultValue `256` */ - dither?: 'none' | 'floyd-steinberg' | 'atkinson' | 'ordered'; + colors?: number; + /** Not with `colors`: give one of them */ + palette?: never; +} | { /** - * How strongly to dither, from `0` (no spreading or threshold, the same as `'none'`) to `1` (the full error or - * threshold). Default: `1` + * 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 */ - ditherStrength?: number; -} + palette: Color[]; + /** Not with `palette`: give one of them */ + colors?: never; +}); -/** How {@link Image.transform} samples the pixels between the source's */ +/** The directions of {@link Image.flip} and {@link Animation.flip}: `'horizontal'` mirrors left and right, `'vertical'` top and bottom */ +export type FlipDirection = 'horizontal' | 'vertical'; + +/** 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']; +const TRANSFORM_MODES: readonly TransformMode[] = ['nearest-neighbor', 'bilinear', 'bicubic']; +const RESIZE_FITS: readonly ResizeFit[] = ['fill', 'inside', 'contain', 'cover']; +const CIRCLE_FITS: readonly NonNullable[] = ['contain', 'cover']; +const SUBSAMPLINGS: readonly string[] = ['4:4:4', '4:2:0']; +/** @internal */ +export const TONE_MAPPINGS: readonly ToneMapping[] = ['reinhard', 'clamp']; +/** @internal */ +export const FLIP_DIRECTIONS: readonly FlipDirection[] = ['horizontal', 'vertical']; /** Options of {@link Image.transform} */ export interface TransformOptions { /** * The point the transform is applied around, in pixels: a rotation turns around it, a scale grows from it. - * Default: the middle of the image. It only decides where the result lies on a canvas that stays as large as the - * image (`expand: false`) + * It only decides where the result lies on a canvas that stays as large as the image (`expand: false`) + * @defaultValue The middle of the image */ origin?: { - /** The x position of the point in pixels. Default: half the width */ + /** + * The x position of the point in pixels + * @defaultValue Half the width + */ x?: number; - /** The y position of the point in pixels. Default: half the height */ + /** + * The y position of the point in pixels + * @defaultValue Half the height + */ y?: number; }; /** * 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. + * @defaultValue `true` */ expand?: boolean; /** * How the pixels between the source's are sampled: `'nearest-neighbor'` (hard pixels, no smoothing, for pixel art), - * `'bilinear'` (smooth), `'bicubic'` (sharper, slower, can overshoot a little at hard edges). Default: `'bilinear'` + * `'bilinear'` (smooth), `'bicubic'` (sharper, slower, can overshoot a little at hard edges) + * @defaultValue `'bilinear'` */ mode?: TransformMode; } /** Options of {@link Image.dropShadow} */ export interface ShadowOptions { - /** How far to move the shadow to the right (negative values: to the left), in pixels. Default: `0` */ + /** + * How far to move the shadow to the right (negative values: to the left), in pixels + * @defaultValue `0` + */ x?: number; - /** How far to move the shadow down (negative values: up), in pixels. Default: `0` */ + /** + * How far to move the shadow down (negative values: up), in pixels + * @defaultValue `0` + */ y?: number; - /** How soft the shadow's edge is: the standard deviation of a gaussian blur in pixels, like {@link Image.blur}. Default: `0` */ + /** + * How soft the shadow's edge is: the standard deviation of a gaussian blur in pixels, like {@link Image.blur} + * @defaultValue `0` + */ blur?: number; - /** How far to grow the shadow's shape before blurring it, in pixels. Default: `0` */ + /** + * How far to grow the shadow's shape before blurring it, in pixels + * @defaultValue `0` + */ spread?: number; - /** The shadow's color, its alpha is the shadow's opacity. Default: `0x00000080` (50% black) */ + /** + * The shadow's color, its alpha is the shadow's opacity + * @defaultValue `0x00000080` (50% black) + */ color?: Color; /** * Whether to make the image larger so the shadow isn't cut off. Without it, the image keeps its size and the part - * of the shadow outside is lost. Default: `true` + * of the shadow outside is lost + * @defaultValue `true` */ expand?: boolean; } /** Options of {@link Image.outline} */ export interface OutlineOptions { - /** The outline's width in pixels. Default: `1` */ + /** + * The outline's width in pixels + * @defaultValue `1` + */ width?: number; - /** The outline's color. Default: `0x000000ff` */ + /** + * The outline's color + * @defaultValue `0x000000ff` + */ color?: Color; /** * Whether to make the image larger so the outline isn't cut off. Without it, the image keeps its size and the - * outline is cut where the image ends. Default: `true` + * outline is cut where the image ends + * @defaultValue `true` */ expand?: boolean; } @@ -272,9 +311,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[]; } @@ -284,282 +322,420 @@ export interface LinearGradientOptions extends GradientOptions { /** * The direction of the gradient in degrees, like CSS' `linear-gradient()`: `0` goes up, `90` to the right, `180` * down, `270` to the left. The first stop is at the start of the line through the center that touches the - * corners. Default: `180` + * corners + * @defaultValue `180` */ angle?: number; } /** Options of {@link Image.radialGradient} */ export interface RadialGradientOptions extends GradientOptions { - /** The x position of the center in pixels. Default: the middle of the image */ + /** + * The x position of the center in pixels + * @defaultValue The middle of the image + */ x?: number; - /** The y position of the center in pixels. Default: the middle of the image */ + /** + * The y position of the center in pixels + * @defaultValue The middle of the image + */ y?: number; - /** The radius of the last stop in pixels. Default: the distance to the farthest corner */ + /** + * The radius of the last stop in pixels + * @defaultValue The distance to the farthest corner + */ radius?: number; } -/** Options of {@link Image.encode} */ -export interface PNGEncodeOptions { - /** The compression level (0-9), higher is smaller but slower. Default: `4` */ - compression?: 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) + * @defaultValue `90` + */ + quality?: number; + /** + * The map's size as a factor of the image's (0-1) + * @defaultValue The map's own size + */ + scale?: number; +} + +/** Options of {@link Image.encodePNG} */ +export interface PNGEncodeOptions extends EncodeOptions { + /** + * The compression level (0-9), higher is smaller but slower + * @defaultValue `4` + */ + compression?: number; +} + +/** Options of {@link Image.encodeTGA}: the metadata, which TGA keeps in its extension area */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface TGAEncodeOptions extends EncodeOptions {} + +/** Options of {@link Image.encodeGIF}, and of {@link Animation.encodeGIF} with {@link GIFAnimationEncodeOptions} */ +export interface GIFEncodeOptions extends EncodeOptions, DitherOptions { + /** + * The maximum colors in each frame's palette (2-256, including transparency). Frames with fewer colors keep them + * exactly; otherwise ImageScript chooses representative colors + * @defaultValue `256` + */ + colors?: number; +} + /** 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 (0-100, 0 is the same as 1) + * @defaultValue `90` + */ quality?: number; - /** Metadata to include, see {@link Metadata} */ - metadata?: Metadata; + /** + * 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) + * @defaultValue `'4:2:0'` up to quality 90, `'4:4:4'` above + */ + subsampling?: '4:4:4' | '4:2:0'; + /** + * The background color to combine with transparent pixels, since JPEG doesn't support transparency. Opaque images + * are encoded as they are + * @defaultValue `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) + * @defaultValue `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 + * 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 (lossless images are encoded the same at + * `2` and above) + * @defaultValue `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 + * @defaultValue `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 + * @defaultValue `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 + * @remarks + * Where workers can't start, the work runs on the calling thread with the same output. That includes browser pages + * that aren't cross-origin isolated and ones that load the package from another origin (a CDN such as jsDelivr), + * which browsers don't allow to start workers + * @defaultValue The available processors, up to 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} */ export interface RoundCornersOptions { - /** The radius of the corners. Default: a quarter of the smaller side */ + /** + * The radius of the corners, 0 or more; one above half the smaller side is cut to that + * @defaultValue A quarter of the smaller side + */ radius?: number; } /** Options of {@link Image.fisheye} */ export interface FisheyeOptions { - /** The distortion strength (exponent of the radial mapping), higher values bulge the center more. Default: `2` */ + /** + * The distortion strength (exponent of the radial mapping), above 0: higher values bulge the center more. At 1 and + * below, the picture shrinks into the middle of the image, and 0 or less would leave nothing of it (it throws) + * @defaultValue `2` + */ strength?: number; } -/** Options of {@link Image.renderSVG} */ -export interface SVGOptions { - /** The scale, or the width or height in pixels, see `mode`. Default: `1` */ - size?: number; +/** The limits, fonts and images of {@link Image.renderSVG}, part of {@link RenderSVGOptions} */ +export interface SVGResourceOptions { /** - * What `size` is: one of {@link SVGScaleModes.SCALE}, {@link SVGScaleModes.WIDTH}, {@link SVGScaleModes.HEIGHT}. - * Default: {@link SVGScaleModes.SCALE} + * Maximum total pixels in the rendered output. Output over the limit throws a RangeError before anything is + * rendered. It's also the default of {@link SVGResourceOptions.maxLayerPixels} and + * {@link SVGResourceOptions.maxImagePixels} + * @defaultValue `268402689` (16383²); `Infinity` disables the limit */ - mode?: SVGScaleMode; + maxPixels?: number; /** - * Fonts for `` (TrueType, OpenType): each text gets the face its `font-family`, `font-weight`, `font-style` + * Maximum pixels of the layers rendering uses at once, the output's included. Groups with opacity, masks, clip + * paths of nested viewports, filters and blend modes are each rendered into a layer of their own (up to the + * output's size, 16 bytes per pixel) while their content is drawn, so deeply nested ones multiply the memory a + * small SVG takes. Exceeding it throws a RangeError. With the defaults, rendering can take about 90 bytes per pixel + * of `maxPixels` (64 for the layers, up to 20 for the images, 4 for the output): lower `maxPixels` to bound it for + * untrusted SVGs + * @defaultValue `4 * maxPixels`; `Infinity` disables the limit + */ + maxLayerPixels?: number; + /** + * Maximum total pixels of the raster images the SVG references (data URIs and {@link SVGResourceOptions.images}), + * decoded together; already-decoded {@link Image} values are used as supplied and don't count. Drawing an image + * beyond the limit throws a RangeError + * @defaultValue `maxPixels`; `Infinity` disables the limit + */ + maxImagePixels?: number; + /** + * 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 * 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 + * @defaultValue `[]` */ - fonts?: BinaryData[]; + font?: 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, * JPEG, WebP, GIF's first frame, ...) or {@link Image}s, decoded like data URI images are, and only for the hrefs - * the SVG references. Nothing else is read: no files and no URLs, so an href that isn't given (and isn't a data URI) - * draws nothing. SVG documents can't be given this way, use a data URI for them. Default: `{}` + * the SVG references. Nothing else is read: no files and no URLs, so drawing an image whose href isn't given (and + * isn't a data URI) throws, as does drawing one that can't be decoded. SVG documents can't be given this way, use a + * data URI for them + * @defaultValue `{}` */ 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.renderSVG}: the size (one of `width`, `height` and `scale`, TypeScript reports a call with + * more; by default the SVG's own size), and the limits, fonts and images (see {@link SVGResourceOptions}) + */ +export type RenderSVGOptions = SVGResourceOptions & ({ + /** + * The width in pixels, the height follows the SVG's aspect ratio + * @defaultValue The SVG's own width + */ + width?: number; + /** Not with `width`: give one of `width`, `height` and `scale` */ + height?: never; + /** Not with `width`: give one of `width`, `height` and `scale` */ + scale?: never; +} | { + /** Not with `height`: give one of `width`, `height` and `scale` */ + width?: never; + /** The height in pixels, the width follows the SVG's aspect ratio */ + height: number; + /** Not with `height`: give one of `width`, `height` and `scale` */ + scale?: never; +} | { + /** Not with `scale`: give one of `width`, `height` and `scale` */ + width?: never; + /** Not with `scale`: give one of `width`, `height` and `scale` */ + height?: never; + /** + * A factor of the SVG's own size (`2` renders it twice as large) + * @defaultValue `1` + */ + scale: number; +}); -/** 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 TextStyleOptions}) + */ +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) */ size: number; - /** The text color. Default: `0xffffffff` */ + /** + * The text color + * @defaultValue `0xffffffff` + */ color?: Color; - /** The text layout, as {@link TextLayoutOptions} or a {@link TextLayout}. Default: the defaults of `TextLayout` */ - layout?: TextLayoutOptions | TextLayout; + /** + * Maximum total pixels of the rendered image (the outline included): larger text throws a RangeError before its + * pixels are allocated + * @defaultValue `268402689` (16383²); `Infinity` disables the limit + */ + maxPixels?: number; } -/** 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 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 + * @defaultValue `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 + * @defaultValue `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. 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. The decoded image + * takes 4 bytes per pixel (about 1 GiB at the default), and decoding needs more while it runs: for 8-bit images, + * about 4-7 bytes per pixel for PNG, JPEG, WebP, GIF and the simpler formats, about 14 for AVIF and about 30 for + * JPEG XL (more with high bit depths). Lower it to bound the memory of servers decoding untrusted files + * @defaultValue `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 + * @defaultValue `'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) 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 + * @defaultValue `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 + * @remarks + * Where workers can't start, the work runs on the calling thread with the same output. That includes browser pages + * that aren't cross-origin isolated and ones that load the package from another origin (a CDN such as jsDelivr), + * which browsers don't allow to start workers + * @defaultValue The available processors, up to 8 (one in runtimes that cannot share memory between workers) */ threads?: number; } /** 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 + * 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 (lossy images only: lossless images are encoded the same at `1` and `2`) + * @defaultValue `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 + * @remarks + * Where workers can't start, the work runs on the calling thread with the same output. That includes browser pages + * that aren't cross-origin isolated and ones that load the package from another origin (a CDN such as jsDelivr), + * which browsers don't allow to start workers + * @defaultValue The available processors, up to 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) + * Whether to write the image's {@link Image.gainMap}, the extra data used to show brighter highlights on HDR + * displays + * @defaultValue `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 { - /** 0-100, like avifenc's: higher is better and larger, 100 is lossless. Default: `60` */ +export interface AVIFEncodeOptions extends EncodeOptions { + /** + * 0-100, like avifenc's: higher is better and larger, 100 is lossless + * @defaultValue `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 + * @defaultValue `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 + * @defaultValue `'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` */ + /** + * How hard the encoder searches for the best modes: 0-2, faster to smaller + * @defaultValue `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 + * @remarks + * Where workers can't start, the work runs on the calling thread with the same output. That includes browser pages + * that aren't cross-origin isolated and ones that load the package from another origin (a CDN such as jsDelivr), + * which browsers don't allow to start workers + * @defaultValue The available processors, up to 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) + * Whether to write the image's {@link Image.gainMap}, the extra data used to show brighter highlights on HDR + * displays + * @defaultValue `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} */ -export interface TextStyle { - /** Width of an outline around the glyphs in pixels. Default: `0` */ +export interface TextStyleOptions { + /** + * Width of an outline around the glyphs in pixels + * @defaultValue `0` + */ strokeWidth?: number; - /** Color of the outline. Default: `0x000000ff` */ + /** + * Color of the outline + * @defaultValue `0x000000ff` + */ strokeColor?: Color; /** * Whether to run the hinting instructions of TrueType fonts, which align stems and edges with the pixel grid for * crisper text at small sizes (like Windows renders text); the glyphs of hinted fonts are then placed on whole - * pixels. Fonts without instructions (and CFF/OpenType outlines) aren't affected. Default: `true` + * pixels. Fonts without instructions (and CFF/OpenType outlines) aren't affected + * @defaultValue `true` */ hinting?: boolean; /** * OpenType features by their tags, for the whole text: `true` turns one on (like `tnum` for tabular numbers, * `smcp` for small caps, `ss01` for a stylistic set), `false` off (also the ones on by default, like `liga` or - * `kern`), a number picks an alternate (`salt: 2`). Fonts without a feature ignore it. Default: `{}` + * `kern`), a number picks an alternate (`salt: 2`). Fonts without a feature ignore it + * @defaultValue `{}` */ features?: Record; /** @@ -568,7 +744,8 @@ export interface TextStyle { * right), `italic` (`true`/`false`), `opticalSize` (the size the glyphs are designed for, in pixels; default: the * font size, like browsers' `font-optical-sizing: auto`), and other axes by their 4-character tag (like Roboto * Flex's `GRAD: 50`). Values outside an axis's range are clamped, axes a font doesn't have are ignored, fonts - * without axes aren't affected. Default: `{}` (the fonts' default instances) + * without axes aren't affected + * @defaultValue `{}` (the fonts' default instances) */ variations?: Record; } @@ -576,41 +753,53 @@ export interface TextStyle { /** Options of {@link Image.cropCircle} */ export interface CropCircleOptions { /** - * The circle's size: `'contain'` fits it inside the image (the smaller dimension is its diameter, no hard edges), + * How the circle fits the image: `'contain'` fits it inside the image (the smaller dimension is its diameter, no hard edges), * `'cover'` covers the image (the larger dimension is its diameter, the corners of a rectangular image stay - * transparent on the shorter side). Default: `'contain'` + * transparent on the shorter side) + * @defaultValue `'contain'` */ - size?: 'contain' | 'cover'; + fit?: '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 + * Share of the radius (0 to 1, a value outside of it throws) over which the edge fades out + * @defaultValue `0` */ - max?: boolean; - /** Share of the radius (0..1) over which the edge fades out. Default: `0` */ feathering?: number; } /** Options of {@link Image.dominantColor} */ export interface DominantColorOptions { - /** Whether to ignore dark colors (all channels below the threshold). Default: `true` */ + /** + * Whether to ignore dark colors (all channels below `threshold`) + * @defaultValue `true` + */ ignoreBlack?: boolean; - /** Whether to ignore light colors (all channels above 255 minus the threshold). Default: `true` */ + /** + * Whether to ignore light colors (all channels above 255 minus `threshold`) + * @defaultValue `true` + */ ignoreWhite?: boolean; - /** The black/white threshold (0-64, in steps of 4 channel values). Default: `15` */ + /** + * How close (in channel values, 0-256) to black or white a color has to be to be ignored + * @defaultValue `60` + */ threshold?: number; } /** 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 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` + * 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 + * @defaultValue `true` + */ + expand?: boolean; + /** + * 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} + * @defaultValue `'bilinear'` */ - fast?: boolean; + mode?: TransformMode; } /** Options of {@link Image.trim} */ @@ -622,7 +811,8 @@ export interface TrimOptions { color?: Color; /** * How far (0-1) a pixel's channels may be from the color for it to be trimmed, e.g. for JPEG artifacts or - * antialiasing. Default: `0` + * antialiasing + * @defaultValue `0` */ tolerance?: number; } @@ -631,21 +821,21 @@ export interface TrimOptions { export interface ColorToAlphaOptions { /** The color to remove, by default the color of the top left pixel (e.g. a background) */ color?: Color; - /** Pixels at most this far from the color (0-1) become fully transparent. Default: 0 */ + /** + * Pixels at most this far from the color (0-1) become fully transparent + * @defaultValue 0 + */ transparencyThreshold?: number; - /** Pixels at least this far from the color (0-1) stay fully opaque. Default: 1 */ + /** + * Pixels at least this far from the color (0-1) stay fully opaque + * @defaultValue 1 + */ 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]> { + // eslint-disable-next-line no-restricted-syntax -- the OpenType features are only used by the text functions if (typeof features !== 'object' || features === null) throw new TypeError('Features must be an object'); return Object.entries(features).map(([tag, value]) => { if (!/^[\x20-\x7e]{4}$/.test(tag)) throw new RangeError(`Invalid OpenType feature tag: ${JSON.stringify(tag)}`); @@ -695,15 +885,18 @@ export class Image { * Creates a new image with the given dimensions, all pixels transparent black * @param width The width in pixels * @param height The height in pixels + * @throws `RangeError` if the width or height is not a number of at least 1, or the image is too large to allocate */ 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; @@ -711,8 +904,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__; @@ -720,16 +914,21 @@ export class Image { /** * Replaces the gain map + * @remarks + * The image uses a copy of the map: changing the image (resizing it, say) changes its own map only, also when the + * same map was given to other images, and later changes to the map you gave don't reach the image * @param gainMap The new gain map, or `null` to remove it + * @throws `TypeError` if it isn't a {@link GainMap} or `null` */ set gainMap(gainMap: GainMap | null) { if (gainMap !== null && !(gainMap instanceof GainMap)) throw new TypeError('The gain map has to be a GainMap or null'); - this.__gain_map__ = gainMap; + this.__gain_map__ = gainMap?.clone() ?? null; } /** - * 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 starting at a byte offset divisible by 4 (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__; @@ -737,13 +936,17 @@ export class Image { /** * Replaces the pixel data - * @param bitmap The new RGBA data, width * height * 4 bytes (used as is, without copying) + * @param bitmap The new RGBA data, width * height * 4 bytes (used as is, without copying, so its byte offset has to be + * divisible by 4: the image reads it as 32-bit pixels too) */ set bitmap(bitmap: Uint8ClampedArray | Uint8Array) { if (!(bitmap instanceof Uint8ClampedArray || bitmap instanceof Uint8Array)) throw new TypeError('The bitmap has to be a Uint8ClampedArray or Uint8Array'); if (bitmap.length !== this.width * this.height * 4) throw new RangeError(`The bitmap has to have ${this.width * this.height * 4} bytes (width * height * 4), got ${bitmap.length}`); + // (a copy would no longer be the array the caller has) + if (bitmap.byteOffset % 4) + throw new TypeError(`The bitmap has to start at a byte offset divisible by 4 to be used without copying, not ${bitmap.byteOffset} (pass bitmap.slice() for a copy)`); this.__set_bitmap__(bitmap instanceof Uint8ClampedArray ? bitmap : new Uint8ClampedArray(bitmap.buffer, bitmap.byteOffset, bitmap.length)); } @@ -754,7 +957,7 @@ export class Image { * @ignore */ protected __set_bitmap__(bitmap: Uint8ClampedArray): void { - // 32-bit views need an aligned offset + // 32-bit views need an aligned offset (the bitmap setter only takes aligned ones, internal pixels get copied) if (bitmap.byteOffset % 4) bitmap = bitmap.slice(); this.__bitmap__ = bitmap; this.__view__ = new DataView(bitmap.buffer, bitmap.byteOffset, bitmap.byteLength); @@ -767,31 +970,19 @@ 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__; } - /** - * Yields an [x, y] array for every pixel in the image (0-based), row by row - * @yields The coordinates of the pixel ([x, y]) - * @deprecated Use {@link Image.pixels}, which yields `[x, y, color]`: `coordinates()` will be removed in 4.0 - * @group Deprecated - */ - *coordinates(): Generator<[x: number, y: number], void, undefined> { - for (let y = 0; y < this.height; y++) - for (let x = 0; x < this.width; x++) - yield [x, y]; - } - /** * Yields an [x, y, color] array for every pixel in the image (0-based), row by row * @yields The coordinates and color of the pixel ([x, y, color]) @@ -804,109 +995,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.pixels}, which yields `[x, y, color]`: 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(); - } - - /** - * 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 @@ -915,10 +1003,13 @@ 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'); const image = new Image(width, height); + // (sizes with a fraction are truncated by the image: the pixels are written to that size) + ({ width, height } = image); const radians = angle * Math.PI / 180; // the direction (y points down, 0 degrees up), and the length of the line through the center to the corners const dx = Math.sin(radians), dy = -Math.cos(radians); @@ -938,12 +1029,15 @@ export class Image { * @returns The new image */ static radialGradient(width: number, height: number, options: RadialGradientOptions): Image { + checkOptions(options, 'Image.radialGradient(100, 50, { stops })'); + const image = new Image(width, height); + // (sizes with a fraction are truncated by the image: the pixels are written to that size) + ({ width, height } = image); 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'); finite(cy, 'y'); if (!(finite(radius, 'radius') > 0)) throw new RangeError('Invalid radius'); - const image = new Image(width, height); const bitmap = new DataView(image.bitmap.buffer, image.bitmap.byteOffset, image.bitmap.byteLength); for (let y = 0, i = 0; y < height; y++) for (let x = 0; x < width; x++, i += 4) @@ -952,53 +1046,32 @@ export class 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) + * 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`) + * @throws `RangeError` if the position isn't inside the image + * @see {@link Image.pixels} to go over every pixel */ getPixelAt(x: number, y: number): Color { - x = ~~finite(x, 'x'); - y = ~~finite(y, 'y'); + x = Math.trunc(finite(x, 'x')); + y = Math.trunc(finite(y, 'y')); this.__check_boundaries__(x, y); 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 * @param y The y coordinate - * @param pixelColor The new color (`0xRRGGBBAA`) + * @param color The new color (`0xRRGGBBAA`) + * @throws `RangeError` if the position isn't inside the image */ - setPixelAt(x: number, y: number, pixelColor: Color): this { - x = ~~finite(x, 'x'); - y = ~~finite(y, 'y'); + setPixelAt(x: number, y: number, color: Color): this { + x = Math.trunc(finite(x, 'x')); + y = Math.trunc(finite(y, 'y')); this.__check_boundaries__(x, y); - this.__set_pixel__(x, y, checkColor(pixelColor)); + this.__set_pixel__(x, y, checkColor(color)); return this; } @@ -1040,25 +1113,28 @@ export class Image { /** * Fills the image data with the supplied color - * @param color The color, or a function returning the color of each pixel + * @param color The color, or a function returning the color of each pixel (checked like any color: a result that + * isn't a number throws a TypeError, a number that isn't a color a RangeError) */ fill(color: Color | ColorFunction): this { const view = new DataView(this.bitmap.buffer, this.bitmap.byteOffset, this.bitmap.byteLength); if (typeof color !== 'function') color = checkColor(color); - // (the new pixels have no HDR light beyond their own: no boost anywhere) - if (this.__gain_map__) this.__gain_map__ = gain.neutralMap(this.__gain_map__, this.width, this.width, this.height); if (typeof color === 'function') { + // (written to a copy first: a function that throws leaves the image as it was) + const pixels = new Uint8ClampedArray(this.bitmap.length), out = new DataView(pixels.buffer); let offset = 0; for (let y = 0; y < this.height; y++) for (let x = 0; x < this.width; x++, offset += 4) - view.setUint32(offset, color(x, y), false); + out.setUint32(offset, checkColor(color(x, y)), false); + this.bitmap.set(pixels); } else { - if (!this.bitmap.length) return this; view.setUint32(0, color, false); const u32 = new Uint32Array(this.bitmap.buffer, this.bitmap.byteOffset, this.bitmap.byteLength / 4); u32.fill(u32[0]); } + // (the new pixels have no HDR light beyond their own: no boost anywhere) + if (this.__gain_map__) this.__gain_map__ = gain.neutralMap(this.__gain_map__, this.width, this.width, this.height); return this; } @@ -1077,35 +1153,13 @@ export class 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 + * 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 */ - static readonly RESIZE_AUTO: typeof ResizeModes.AUTO = ResizeModes.AUTO; + declare __moved__?: (scaleX: number, scaleY: number, x: number, y: number) => void; /** * Resizes the image by the given factor @@ -1113,17 +1167,13 @@ 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); - const image = this.__scale__(factor, mode, fast); + scale(factor: number, options?: ResizeOptions): this { + checkOptions(options, 'scale(factor, { mode: \'area\' })'); + const { mode } = resizeOptions(options); + finite(factor, 'scale factor'); + const image = this.__scale__(factor, mode); 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); } @@ -1132,122 +1182,77 @@ export class Image { * @internal * @ignore */ - protected __scale__(factor: number, mode: ResizeMode = ResizeModes.BILINEAR, fast = true): Image { - if (factor === 1) return this; - return this.__resize__(this.width * factor, this.height * factor, mode, fast); - } - - /** - * 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 options See {@link ResizeOptions} - * @returns The resized image - */ - resize(width: number, height: number, options?: ResizeOptions): this; + protected __scale__(factor: number, mode: ResizeMode = 'bilinear'): Image { + if (factor === 1) return this; + // (a side doesn't shrink below one pixel, like resize({ width }) keeping the aspect ratio) + const size = (side: number): number => factor > 0 ? Math.max(1, side * factor) : side * factor; + return this.__resize__(size(this.width), size(this.height), mode); + } + /** * 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 + * @param options See {@link ResizeOptions} * @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; + resize(width: number, height: number, options?: ResizeOptions): this; /** * Resizes the image: to the given width or height (the other side keeps the aspect ratio), or to both + * @example + * ```ts + * image.resize({ width: 300, height: 200, fit: 'cover' }); // fills 300x200, cropping what doesn't fit + * image.resize({ width: 150 }); // 150 wide, the height keeps the aspect ratio + * ``` * @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); - const image = this.__resize__(width, height, mode, fast); + 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 } = resizeOptions(options); + const image = this.__resize__(...this.__resized_size__(width, height, fit), mode); 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); } /** - * 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} - * @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 + * Resizes and centers the image in a box of exactly that size, with transparent or colored bars + * @internal */ - contain(width: number, height: number, mode?: ResizeMode): this; - contain(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this { - const options = resizeOptions(modeOrOptions); + private __contain__(width: number, height: number, options?: ResizeRuntimeOptions): this { + 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))), options); - } - - /** - * 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; - /** - * 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); + // (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); + 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); } /** - * 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 - */ - 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 + * Resizes the image to cover the box, cropping the overflowing edges + * @internal */ - cover(width: number, height: number, mode?: ResizeMode): this; - cover(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this { - const options = resizeOptions(modeOrOptions); + private __cover__(width: number, height: number, options?: ResizeOptions): this { + 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) @@ -1258,14 +1263,16 @@ 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 !== '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; + // (resampled first: this can fail to allocate, and the gain map has to stay as it was then) + const resized = this.__resize__(resizedWidth, resizedHeight, resizeOpts.mode, region); 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)); + this.__moved__?.(fx, fy, region.x, region.y); + return this.__apply__(resized); } - this.resize(resizedWidth, resizedHeight, options); + this.resize(resizedWidth, resizedHeight, resizeOpts); return this.crop((this.width - width) / 2, (this.height - height) / 2, width, height); } @@ -1274,11 +1281,20 @@ 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, 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)))]; + return [width, height]; + } + /** @private */ + /** + * @internal + * @ignore + */ + protected __resize__(width: number, height: number, mode: ResizeMode = 'bilinear', region?: Region): Image { width = Math.floor(width); height = Math.floor(height); if (width < 1) @@ -1286,13 +1302,15 @@ 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); + oneOf(mode, RESIZE_MODES, 'resize mode'); + 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; - if (!filter) throw new Error('Invalid resize mode'); + const filter = mode === 'bilinear' ? TRIANGLE : mode === 'bicubic' ? CUBIC : BOX; const image = new Image(region?.width ?? width, region?.height ?? height); - resample(this.bitmap, this.width, this.height, width, height, filter, image.bitmap, fast, region); + // (8-bit rounding between the passes and for transparent images, like browsers: what shows differs from the precise + // result by a step or two at most, see tests/resize.js) + resample(this.bitmap, this.width, this.height, width, height, filter, image.bitmap, true, region); return image; } @@ -1325,12 +1343,13 @@ 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); this.__crop_gain_map__(this, ...bounds); + this.__moved__?.(1, 1, bounds[0], bounds[1]); return this.__apply__(this.__crop__(...bounds)); } @@ -1348,7 +1367,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) @@ -1356,7 +1375,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); @@ -1369,10 +1388,10 @@ export class Image { * @ignore */ protected __crop_bounds__(x: number, y: number, width: number, height: number): [number, number, number, number] { - x = ~~finite(x, 'x'); - y = ~~finite(y, 'y'); - width = ~~finite(width, 'width'); - height = ~~finite(height, 'height'); + x = Math.trunc(finite(x, 'x')); + y = Math.trunc(finite(y, 'y')); + width = Math.trunc(finite(width, 'width')); + height = Math.trunc(finite(height, 'height')); 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); @@ -1384,26 +1403,17 @@ 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; - /** - * 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); + this.__moved__?.(1, 1, bounds[0], bounds[1]); return this.__apply__(this.__crop__(...bounds)); } @@ -1412,7 +1422,8 @@ 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 { + finite(tolerance, 'tolerance'); if (!(tolerance >= 0 && tolerance <= 1)) throw new RangeError('Invalid tolerance, it has to be between 0 and 1'); const bitmap = this.bitmap; @@ -1464,132 +1475,17 @@ 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} - */ - 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 }; - 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; + * @see {@link Image.roundCorners} for rounded corners + */ + cropCircle(options: CropCircleOptions = {}): this { + checkOptions(options, 'cropCircle({ fit: \'cover\' })'); + const { fit = 'contain', feathering = 0 } = options; + unitInterval(feathering, 'feathering'); + oneOf(fit, CIRCLE_FITS, 'fit'); + const radius = (fit === 'cover' ? Math.max : Math.min)(this.width, this.height) / 2; const cx = this.width / 2, cy = this.height / 2; // width of the soft edge, at least the one pixel of antialiasing const band = Math.min(1, Math.max(0, feathering)) * radius; @@ -1653,24 +1549,14 @@ 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} + * @param factor The factor to multiply the opacity with (0..1 lowers it, >1 raises it, results are clamped) */ - 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 = finite(opacity, 'opacity value'); - if (opacity < 0) + opacity(factor: number): this { + factor = finite(factor, 'opacity value'); + if (factor < 0) throw new RangeError('Invalid opacity value'); - this.__set_channel_value__(opacity, absolute, 3); + this.__set_channel_value__(factor, 3); return this; } @@ -1678,24 +1564,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; } @@ -1703,24 +1579,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; } @@ -1728,24 +1594,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; } @@ -1754,84 +1610,55 @@ 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} + * @param factor The factor to multiply the lightness with (>= 0, results are clamped) */ - 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; - value = finite(value, 'lightness value'); - if (value < 0) - throw new RangeError('Invalid lightness value'); + lightness(factor: number): this { + 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 * (absolute ? 1 : 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) - * @param options See {@link AdjustOptions} + * 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, 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; - value = finite(value, 'saturation value'); - if (value < 0) - throw new RangeError('Invalid saturation value'); + saturation(factor: number): this { + 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 * (absolute ? 1 : c.s); }); + return this.__map_hsla__(c => { c.s = factor * c.s; }); } /** * Composites (overlays) the source onto this image + * @example + * ```ts + * image.composite(overlay, { x: 10, y: 10, opacity: 0.5, mode: 'multiply' }); + * ``` * @param source The image to place * @param options Position, opacity and blend mode, see {@link CompositeOptions} + * @throws `TypeError` if the source isn't an {@link Image} */ 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; - const { opacity = 1, mode = 'normal' } = options; - let x = isOptions(xOrOptions) ? xOrOptions.x ?? 0 : xOrOptions, y = isOptions(xOrOptions) ? xOrOptions.y ?? 0 : yArgument; - x = finite(x, 'x') | 0; - y = finite(y, 'y') | 0; - - if (isNaN(opacity) || opacity < 0 || opacity > 1) - throw new RangeError('Invalid composite opacity'); - const blend = BLEND_MODES[mode]; - if (!blend || !Object.prototype.hasOwnProperty.call(BLEND_MODES, mode)) - throw new RangeError(`Invalid blend mode: ${mode}`); + composite(source: Image, options: CompositeOptions = {}): this { + checkOptions(options, 'composite(source, { x: 10, y: 20 })'); + if (!(source instanceof Image)) throw new TypeError('composite() takes an Image as its source, like image.composite(logo, { x: 10, y: 20 })'); + const { opacity = 1, mode = 'normal', x: givenX = 0, y: givenY = 0 } = options; + const x = Math.trunc(finite(givenX, 'x')), y = Math.trunc(finite(givenY, 'y')); + unitInterval(opacity, 'opacity'); + const blend = BLEND_MODES[oneOf(mode, Object.keys(BLEND_MODES) as BlendMode[], 'blend mode')]; if (opacity === 0) return this; if (this.__gain_map__ || source.__gain_map__) return this.__composite_gain__(source, x, y, opacity, mode); if (mode !== 'normal') return this.__blend__(source, x, y, opacity, blend); @@ -1839,7 +1666,9 @@ export class Image { // (the loop stays inline here: as a method of its own it ran about 20% slower) const left = Math.max(0, x), top = Math.max(0, y); const right = Math.min(this.width, x + source.width), bottom = Math.min(this.height, y + source.height); - const dst = this.bitmap, src = source.bitmap; + const dst = this.bitmap; + // (a source sharing the destination's memory, itself or through the bitmap setter, is read before it is written) + const src = source.bitmap.buffer === dst.buffer ? source.bitmap.slice() : source.bitmap; for (let row = top; row < bottom; row++) { for (let column = left, s = 4 * ((row - y) * source.width + left - x), d = 4 * (row * this.width + left); column < right; column++, s += 4, d += 4) { @@ -1884,7 +1713,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; @@ -1938,7 +1767,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; } @@ -1950,7 +1779,9 @@ export class Image { protected __blend__(source: Image, x: number, y: number, opacity: number, blend: (backdrop: number, source: number) => number): this { const left = Math.max(0, x), top = Math.max(0, y); const right = Math.min(this.width, x + source.width), bottom = Math.min(this.height, y + source.height); - const dst = this.bitmap, src = source.bitmap; + const dst = this.bitmap; + // (a source sharing the destination's memory, itself or through the bitmap setter, is read before it is written) + const src = source.bitmap.buffer === dst.buffer ? source.bitmap.slice() : source.bitmap; for (let row = top; row < bottom; row++) { for (let column = left; column < right; column++) { @@ -1977,7 +1808,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()); @@ -1992,7 +1823,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()); @@ -2000,7 +1831,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 { @@ -2024,7 +1855,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 { @@ -2076,7 +1907,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)); @@ -2104,8 +1935,7 @@ export class Image { * @param radius The blur radius: the gaussian's standard deviation in pixels, like CSS' blur() */ blur(radius: number): this { - if (!Number.isFinite(radius) || radius < 0) - throw new RangeError('Invalid blur radius'); + nonNegative(radius, 'blur radius'); // (the gain map blurred alike, at its resolution) const map = this.__gain_map__; if (map && radius * map.width / this.width >= 0.5) map.image = map.image.clone().blur(radius * map.width / this.width); @@ -2118,17 +1948,22 @@ export class Image { } /** - * Reduces the image to a palette of at most 256 colors (or a fixed one), in place, optionally dithered, e.g. - * `image.quantize({ colors: 16, dither: 'floyd-steinberg' })` or `image.quantize({ palette: [0x0f380fff, 0x306230ff, - * 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 + * Reduces the image to a palette of at most 256 colors (or a fixed one), in place, optionally dithered + * @remarks + * 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 + * @example + * ```ts + * image.quantize({ colors: 16, dither: 'floyd-steinberg' }); + * image.quantize({ palette: [0x0f380fff, 0x306230ff, 0x8bac0fff, 0x9bbc0fff] }); // a Game Boy look + * ``` * @param options See {@link QuantizeOptions} */ quantize(options: QuantizeOptions = {}): this { - if (typeof options !== 'object' || options === null) throw new TypeError('quantize takes an options object'); - const { colors, palette, dither = 'none', ditherStrength = 1 } = options; + checkOptions(options, 'image.quantize({ colors: 16 })'); + // (JavaScript callers can give both: checked below) + const { colors, palette, dither = 'floyd-steinberg', ditherStrength = 0.85 } = options as DitherOptions & { colors?: number, palette?: Color[] }; // everything is checked before the image changes if (colors !== undefined && palette !== undefined) throw new TypeError('quantize takes colors or palette, not both'); @@ -2145,13 +1980,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__(); @@ -2165,15 +1997,19 @@ 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, - * swapped channels, color blindness simulation or a mix of those in one pass. - * + * 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 + * @remarks * The values are 20 numbers, four rows of `[red, green, blue, alpha, offset]` for the new red, green, blue and alpha, * with the colors and the offsets as 0 to 1 (like CSS and SVG): `[1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, * 0, 0, 0, 1, 0]` changes nothing, `[0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0]` swaps red and * blue. Nine numbers are the first three rows without the alpha and the offsets (the alpha stays as it is). The * colors are the sRGB values, not premultiplied, and the results are clamped. With a gain map (an HDR image), the * HDR highlights follow the change as they do for other color changes + * @example + * ```ts + * image.colorMatrix([0.393, 0.769, 0.189, 0.349, 0.686, 0.168, 0.272, 0.534, 0.131]); // sepia + * ``` * @param matrix The 9 or 20 values */ colorMatrix(matrix: ArrayLike): this { @@ -2202,8 +2038,13 @@ export class Image { /** * Moves every point of the image with an affine transform, in one resampling: rotation, scaling, skew, flips and - * shifts in any mix and around any point. The six numbers are SVG's `matrix(a b c d e f)`: a point (x, y) of the - * image ends up at (a x + c y + e, b x + d y + f), with y pointing down. + * shifts in any mix and around any point + * @remarks + * The six numbers are SVG's `matrix(a b c d e f)`: a point (x, y) of the image ends up at (a x + c y + e, + * b x + d y + f), with y pointing down. The image grows to hold the result (`expand: false` keeps its size), so a shift + * only shows with `expand: false`. The edges fade, like the pixels outside were transparent. A transform that flattens + * the image (a factor of 0) is refused. With a gain map (an HDR image), the map moves with the image + * @example * ```ts * image.transform([1, 0, Math.tan(-0.3), 1, 0, 0]); // slanted like italics, around the middle * image.transform([0.5, 0, 0, 0.5, 0, 0], { origin: { x: 0, y: 0 } }); // half the size @@ -2211,19 +2052,17 @@ export class Image { * const a = Math.PI / 6; * image.transform([Math.cos(a), Math.sin(a), -Math.sin(a), Math.cos(a), 0, 0]); // turned 30° clockwise * ``` - * The image grows to hold the result (`expand: false` keeps its size), so a shift only shows with `expand: false`. - * The edges fade, like the pixels outside were transparent. A transform that flattens the image (a factor of 0) is - * refused. With a gain map (an HDR image), the map moves with the image * @param matrix The six values [a, b, c, d, e, f] * @param options See {@link TransformOptions} + * @see {@link Image.rotate} for turning by an angle */ - 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) const values = Array.from({ length: 6 }, (_, i) => finite(typeof matrix[i] === 'number' ? matrix[i] : NaN, 'transform value')); - if (typeof options !== 'object' || options === null) throw new TypeError('transform takes an options object'); const { origin = {}, expand = true, mode = 'bilinear' } = options; - if (!TRANSFORM_MODES.includes(mode)) throw new TypeError(`transform mode must be one of ${TRANSFORM_MODES.map(name => `'${name}'`).join(', ')}, not ${String(mode)}`); + oneOf(mode, TRANSFORM_MODES, 'transform mode'); const { x: pivotX = this.width / 2, y: pivotY = this.height / 2 } = origin; finite(pivotX, 'origin x'); finite(pivotY, 'origin y'); @@ -2249,16 +2088,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`); } @@ -2269,20 +2098,19 @@ 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; + this.__moved__?.(1, 1, left, top); + 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} + * @throws {@link LimitError} if the size or offset of the shadow would make the image larger than an image can be */ 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'); @@ -2315,10 +2143,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 }); @@ -2326,11 +2153,12 @@ 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} + * @throws {@link LimitError} if the width would make the image larger than an image can be */ 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)); @@ -2350,9 +2178,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 }); } @@ -2362,6 +2190,7 @@ export class Image { * @ignore */ protected __shape_layer__(margin: number): Image { + checkGrowth(this.width + 2 * margin, this.height + 2 * margin, 'The shadow or outline'); const layer = new Image(this.width + 2 * margin, this.height + 2 * margin); for (let y = 0; y < this.height; y++) for (let x = 0; x < this.width; x++) @@ -2378,6 +2207,7 @@ export class Image { protected __under__(layer: Image, layerX: number, layerY: number, grow: { left: number, top: number, right: number, bottom: number }): this { const { left, top, right, bottom } = grow; const width = this.width + left + right, height = this.height + top + bottom; + checkGrowth(width, height, 'The shadow or outline'); const result = new Image(width, height); result.composite(layer, { x: layerX + left, y: layerY + top }); const map = this.__gain_map__; @@ -2388,28 +2218,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); - 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) + this.__moved__?.(1, 1, -left, -top); + return this.__apply__(result); } /** @@ -2417,19 +2227,10 @@ 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 }; - if (!Number.isFinite(amount) || amount < 0) - throw new RangeError('Invalid sharpen amount'); - if (!Number.isFinite(radius) || radius <= 0) - throw new RangeError('Invalid sharpen radius'); + sharpen(options: SharpenOptions = {}): this { + checkOptions(options, 'sharpen({ amount: 0.5 })'); + const { amount: givenAmount = 1, radius: givenRadius = 1 } = options; + const amount = nonNegative(givenAmount, 'sharpen amount'), radius = positive(givenRadius, 'sharpen radius'); const blurred = premultiply(this.bitmap); gaussianBlur(blurred, this.width, this.height, radius); @@ -2462,8 +2263,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); @@ -2486,10 +2287,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; @@ -2520,20 +2321,12 @@ 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; + finite(transparencyThreshold, 'transparencyThreshold'); + finite(opacityThreshold, 'opacityThreshold'); if (!(transparencyThreshold >= 0 && opacityThreshold <= 1 && transparencyThreshold < opacityThreshold)) throw new RangeError('Invalid thresholds, they have to satisfy 0 <= transparencyThreshold < opacityThreshold <= 1'); @@ -2583,6 +2376,7 @@ export class Image { /** * Gets the average color of the image, weighting each pixel by its opacity * @returns The average color (fully opaque), black for fully transparent images + * @see {@link Image.dominantColor} for the color that most of the image has */ averageColor(): Color { let r = 0, g = 0, b = 0, weight = 0; @@ -2599,26 +2393,19 @@ export class Image { } /** - * Gets the image's dominant color: the colors are grouped into a few clusters (median cut and k-means), - * the average color of the largest one is returned + * Gets the image's dominant color: the colors are counted in a histogram of 16 levels per channel, and the average + * of the pixels in the fullest bin is returned (so it is a color the image has, not a mix of two hues) * @param options Which colors to ignore, see {@link DominantColorOptions} * @return The image's dominant color (fully opaque); mostly transparent pixels are ignored. * If every pixel is ignored as black or white, the filter is dropped + * @see {@link Image.averageColor} for the mix of all colors */ 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 }; - const threshold = Math.min(64, Math.max(0, finite(bwThreshold, 'threshold'))) * 4; + dominantColor(options: DominantColorOptions = {}): Color { + checkOptions(options, 'dominantColor({ ignoreBlack: false })'); + const { ignoreBlack = true, ignoreWhite = true, threshold: bwThreshold = 60 } = options; + const threshold = finite(bwThreshold, 'threshold'); + if (threshold < 0 || threshold > 256) throw new RangeError(`threshold has to be between 0 and 256, not ${threshold}`); const pixels = this.__bytes__(); const color = quantize.dominantColor(pixels, (r, g, b) => @@ -2629,21 +2416,26 @@ 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}). By default the image grows so the corners fit (a quarter turn swaps width and + * height); with `expand: false` it keeps its size, also for quarter turns, and what doesn't fit is cut off + * @example + * ```ts + * image.rotate(90); // a quarter turn clockwise: width and height swap + * image.rotate(-15, { expand: false }); // 15° counterclockwise, the size stays and the corners are cut off + * ``` + * @param angle The angle in degrees, clockwise * @param options See {@link RotateOptions} + * @see {@link Image.transform} for skews, flips and shifts */ 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 }; - angle = ((finite(angle, 'angle') % 360) + 360) % 360; + rotate(angle: number, options: RotateOptions = {}): this { + checkOptions(options, 'rotate(angle, { expand: false })'); + const { expand = true, mode = 'bilinear' } = options; + oneOf(mode, TRANSFORM_MODES, 'rotate 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; @@ -2671,95 +2463,59 @@ export class Image { else dst[x * height + height - 1 - y] = i; } + if (!expand && out.width !== width) { + // (without `expand` the image keeps its size: the turned image is centered in it, what doesn't fit is cut off) + const canvas = new Image(width, height), target = canvas.__u32__, turned = out.__u32__; + const left = Math.floor((width - out.width) / 2), top = Math.floor((height - out.height) / 2); + for (let y = Math.max(0, top); y < Math.min(height, top + out.height); y++) + for (let x = Math.max(0, left); x < Math.min(width, left + out.width); x++) + target[y * width + x] = turned[(y - top) * out.width + x - left]; + rotateGainMap(width, height); + this.__moved__?.(1, 1, 0, 0); + return this.__apply__(canvas); + } rotateGainMap(out.width, out.height); + this.__moved__?.(1, 1, (width - out.width) / 2, (height - out.height) / 2); return this.__apply__(out); } 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); - 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; - } + // bilinear: 8-bit sampling, about twice as fast, within a step of the precise sampling below (tests/rotate.js) + if (mode === 'bilinear' && rotateBilinear(this.bitmap, width, height, out.bitmap, outWidth, outHeight, sin, cos)) { + this.__moved__?.(1, 1, (width - outWidth) / 2, (height - outHeight) / 2); + return this.__apply__(out); } - + // 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); + this.__moved__?.(1, 1, (width - outWidth) / 2, (height - outHeight) / 2); return this.__apply__(out); } /** * Flips / mirrors the image horizontally or vertically - * @param direction The direction to flip + * @param direction The direction to flip, see {@link FlipDirection} */ - flip(direction: 'horizontal' | 'vertical'): this { + flip(direction: FlipDirection): this { + oneOf(direction, FLIP_DIRECTIONS, 'flip direction'); const u32 = this.__u32__; const {width, height} = this; if (direction === 'horizontal') { for (let y = 0; y < height; y++) u32.subarray(y * width, (y + 1) * width).reverse(); - } else if (direction === 'vertical') { + } else { for (let y = 0; y < height >> 1; y++) { const top = u32.slice(y * width, (y + 1) * width); u32.copyWithin(y * width, (height - 1 - y) * width, (height - y) * width); u32.set(top, (height - 1 - y) * width); } - } else throw new RangeError('Invalid flip direction'); + } this.__gain_map__?.__transform__(width, height, width, height, direction === 'horizontal' ? (X, Y) => [width - X, Y] : (X, Y) => [X, height - Y]); return this; @@ -2814,12 +2570,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 @@ -2836,20 +2586,16 @@ export class Image { } /** - * Rounds the images corners, with antialiased edges + * Rounds the image's corners, with antialiased edges * @param options See {@link RoundCornersOptions} + * @see {@link Image.cropCircle} for a circle */ 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; - radius = finite(radius, 'radius'); - radius = Math.max(0, Math.min(radius, this.width / 2, this.height / 2)); + roundCorners(options: RoundCornersOptions = {}): this { + checkOptions(options, 'roundCorners({ radius: 10 })'); + const { radius: givenRadius = Math.min(this.width, this.height) / 4 } = options; + let radius = nonNegative(givenRadius, 'radius'); + radius = Math.min(radius, this.width / 2, this.height / 2); const size = Math.ceil(radius); // coverage of the top left corner, mirrored onto the others @@ -2870,16 +2616,12 @@ export class Image { /** * Applies a fisheye (barrel distortion) effect * @param options See {@link FisheyeOptions} + * @throws `RangeError` if the strength is 0 or less */ 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 = positive(options.strength === undefined ? 2 : options.strength, 'strength'); const r = new Image(this.width, this.height); const w = this.width; @@ -2919,105 +2661,77 @@ 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 */ - 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; + encodePNG(options?: PNGEncodeOptions): Promise>; + async encodePNG(options: PNGEncodeOptions = {}): Promise> { + checkOptions(options, 'encodePNG({ compression: 9, metadata: { author: \'me\' } })'); + const { compression = 4, metadata = {} } = options; 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 + * 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; - /** - * 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 }; + encodeJPEG(options?: JPEGEncodeOptions): Promise>; + async encodeJPEG(options: JPEGEncodeOptions = {}): Promise> { + checkOptions(options, 'encodeJPEG({ quality: 80 })'); + const { quality: qualityOption = 90, subsampling, background = 0x000000ff, metadata = {}, gainMap: gainMapOption = true } = options; + const quality = checkQuality(qualityOption); + if (subsampling !== undefined) oneOf(subsampling, SUBSAMPLINGS, 'subsampling'); + const backgroundColor = checkColor(background, 'background'); const m = resolveMetadata(metadata); - const base = { exif: exifData(m), comment: m.comment ? utf8(m.comment) : undefined }; - const gainMap = this.__gain_map__; - if (!gainMap || gainMapOption === false) return jpeg.encode(this.__bytes__(), this.width, this.height, finite(quality, 'quality'), base); + const base = { exif: exifData(m), comment: m.comment ? utf8(m.comment) : undefined, subsampling }; + + // 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, 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'); - if (!(s > 0 && s <= 1)) throw new RangeError(`The gain map scale must be above 0 and at most 1, not ${s}`); + const s = gainMapScale(scale); width = Math.max(1, Math.round(this.width * s)); height = Math.max(1, Math.round(this.height * s)); } 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, 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 * @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); @@ -3027,25 +2741,41 @@ 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({ metadata = {} }: EncodeOptions = {}): Promise { + async encodeTGA(options: TGAEncodeOptions = {}): 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 }); } + /** + * 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 */ - 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); } @@ -3053,10 +2783,12 @@ 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)}`); + oneOf(subsampling, SUBSAMPLINGS, 'subsampling'); const exif = exifData(resolveMetadata(metadata)); return avifEnc.encode(this.__bytes__(), this.width, this.height, { quality: checkQuality(quality), alphaQuality: checkQuality(alphaQuality, 'alphaQuality'), subsampling, effort, exif, threads, gainMap: this.__gain_map_pixels__(gainMap) @@ -3076,8 +2808,7 @@ export class Image { const mapQuality = Math.max(1, checkQuality(quality, 'gain map quality')); let map = gainMap.image; if (scale !== undefined) { - const s = finite(scale, 'gain map scale'); - if (!(s > 0 && s <= 1)) throw new RangeError(`The gain map scale must be above 0 and at most 1, not ${s}`); + const s = gainMapScale(scale); const width = Math.max(1, Math.round(this.width * s)), height = Math.max(1, Math.round(this.height * s)); if (width !== map.width || height !== map.height) map = map.clone().resize(width, height); } @@ -3085,16 +2816,19 @@ 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 - * (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 - * container, with the metadata as Exif + * Encodes the image as JPEG XL: losslessly (the default), or lossy with a quality. Alpha (if the image has + * transparency) is lossless either way. The codestream is in the container, with the metadata as Exif + * @remarks + * Lossless works 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; 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). Lossy works like `cjxl -q`: XYB, varblocks + * of libjxl's search, chroma from luma, the edge preserving filter * @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) @@ -3105,28 +2839,40 @@ 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 { + async encodePNM(): Promise> { return netpbm.encode(this.__bytes__(), this.width, this.height); } /** * 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) + * @remarks + * 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 + * @example + * ```ts + * const image = await Image.decode(bytes); // the file's bytes, e.g. from fs.readFile() or fetch() + * const webp = await image.resize({ width: 800 }).encodeWebP({ quality: 80 }); + * ``` * @param input The binary data to decode - * @param options See {@link DecodeOptions}. JPEGs, TIFFs and PNGs are rotated/mirrored as their orientation tag + * @param options See {@link DecodeOptions}. JPEGs, TIFFs, PNGs, WebPs and JPEG XLs are rotated/mirrored as their orientation * (Exif, for PNGs also XMP) says by default: photos taken with a rotated camera are stored sideways * @return The decoded image + * @throws {@link LimitError} if the image has more than `options.maxPixels` pixels (checked before its pixels are + * allocated) + * @throws `Error` if the data isn't a supported image, or is damaged or truncated (the underlying error is its `cause`) */ 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}`); - if (toneMapping !== 'reinhard' && toneMapping !== 'clamp') throw new TypeError(`toneMapping must be 'reinhard' or 'clamp', not ${String(toneMapping)}`); + pixelLimit(maxPixels); + oneOf(toneMapping, TONE_MAPPINGS, 'toneMapping'); const data = view(input); const dataView = new DataView(data.buffer, data.byteOffset, data.byteLength); @@ -3135,8 +2881,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 }> => { @@ -3148,12 +2896,18 @@ 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); 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'); }); @@ -3162,15 +2916,18 @@ export class Image { const { icc, cicp, colorSpace } = decoded; (icc ? profileTransform(icc) : cicp ? cicpTransform(...cicp) : colorSpace ? colorSpaceTransform(colorSpace.xy, colorSpace.curve) : null)?.(decoded.pixels); } + // (a decoder that reads sizes from damaged data can end up with none, or with other pixels than they say) + if (!(decoded.width >= 1 && decoded.height >= 1) || decoded.pixels.length !== decoded.width * decoded.height * 4) + throw new Error('Image: damaged or truncated image data'); // the decoded pixels become the bitmap, without a copy const image = new Image(decoded.width, decoded.height); - image.bitmap = decoded.pixels; + image.bitmap = alignedPixels(decoded.pixels); // (a gain map that can't be read leaves the image without one, it doesn't fail the decoding) if (gainMap && ImageType.isJPEG(dataView)) { try { const found = gainMapCodec.find(data); const size = found && gainMapCodec.dimensions(found.map); - if (found && size && size.width * size.height <= maxPixels) image.__gain_map__ = GainMap.__from_jpeg__(found.map, found.params, found.apple); + if (found && size && size.width * size.height <= maxPixels) image.__gain_map__ = GainMap.__from_jpeg__(found.map, found.params, found.apple, maxPixels); } catch { image.__gain_map__ = null; } @@ -3178,7 +2935,7 @@ export class Image { if (decoded.gainMap) { const { width, height, pixels, params } = decoded.gainMap; const map = new Image(width, height); - map.bitmap = pixels; + map.bitmap = alignedPixels(pixels); image.__gain_map__ = GainMap.__from_pixels__(map, params); } if (applyOrientation && decoded.orientation && decoded.orientation > 1) { @@ -3229,30 +2986,16 @@ 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 + * @remarks * 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 SVGResourceOptions.images}, and text with the given or embedded fonts + * (see {@link SVGResourceOptions.font}). Hrefs are resolved from the options and the data URIs only, never from files or URLs: + * drawing an image that isn't given, or can't be decoded, throws. SVGs from untrusted sources are fine to render: the + * size of the output, the layers and the images are limited by the options, and so is the work nested masks, clip + * paths, patterns, markers and filter images can multiply * @example * ```ts * const logo = await Image.renderSVG( @@ -3261,33 +3004,35 @@ export class Image { * ); * ``` * @param svg The SVG content - * @param options See {@link SVGOptions} - * @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}) + * @param options See {@link RenderSVGOptions} * @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: givenMode = SVGScaleModes.SCALE, fonts = [], images: givenImages = {} } = isOptions(sizeOrOptions) ? sizeOrOptions : { size: sizeOrOptions, mode: modeArgument }; - const mode = LEGACY_SVG_MODES.get(givenMode) ?? givenMode; - if (mode !== 'scale' && mode !== 'width' && mode !== 'height') - throw new Error('Invalid SVG scaling mode'); - - finite(size, 'SVG size'); - if (mode === SVGScaleModes.SCALE && size <= 0) + * @throws {@link LimitError} if the output, the layers or the images exceed the limits of the options + * @throws `RangeError` if the requested size is below 1 pixel (or a scale isn't above 0) + * @throws `Error` if the SVG can't be parsed (it has no `` element), an image in it can't be drawn (it isn't given + * or can't be decoded), or masks, clip paths, patterns, markers and filter images are nested so deeply that they + * would multiply + */ + 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, maxPixels = MAX_PIXELS, font: fonts = [], images: givenImages = {} } = options; + pixelLimit(maxPixels); + const { maxLayerPixels = 4 * maxPixels, maxImagePixels = maxPixels } = options; + pixelLimit(maxLayerPixels, 'maxLayerPixels'); + pixelLimit(maxImagePixels, 'maxImagePixels'); + 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) + // eslint-disable-next-line no-restricted-syntax -- the SVG size is only checked here 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) + // eslint-disable-next-line no-restricted-syntax -- the images of an SVG are only checked here throw new TypeError('SVG images must be an object of hrefs to image data'); const given = new Map(); for (const href of Object.keys(givenImages)) { @@ -3298,29 +3043,42 @@ export class Image { } // the raster images it references are decoded first (decoding can be asynchronous), the SVG is rendered at once. - // A given image wins over a data URI with the same href; each is decoded once, and only if the SVG uses it + // A given image wins over a data URI with the same href; each is decoded once, and only if the SVG references + // it. One that can't be decoded (or doesn't fit maxImagePixels) throws once it's drawn const source = typeof svg === 'string' ? svg : view(svg); - const images = new Map(); + const images = new Map(); + const shown = (href: string): string => JSON.stringify(href.length > 100 ? `${href.slice(0, 100)}...` : href); + let imagePixels = maxImagePixels; for (const { href, data } of svgRenderer.imageReferences(source)) { const value = given.get(href) ?? data; if (!value) continue; + if (value instanceof Image) { + images.set(href, { width: value.width, height: value.height, pixels: value.bitmap }); + continue; + } + const tooLarge = (cause?: unknown): LimitError => new LimitError(`SVG: the image ${shown(href)} exceeds what's left of the ${maxImagePixels} pixels the SVG's images may decode to (the render options' maxImagePixels)`, { cause }); + if (!(imagePixels >= 1)) { + images.set(href, { error: tooLarge() }); + continue; + } try { - if (value instanceof Image) { - images.set(href, { width: value.width, height: value.height, pixels: value.bitmap }); - } else if (ImageType.getType(value) === 'gif') { + let decoded: svgRenderer.RasterImage; + if (ImageType.getType(value) === 'gif') { // (a GIF's first frame) - const decoded = gif.decode(view(value), true); - images.set(href, { width: decoded.width, height: decoded.height, pixels: gif.composite(decoded)[0].pixels }); + const gifData = await decoding(() => gif.decode(view(value), true, imagePixels)); + decoded = { width: gifData.width, height: gifData.height, pixels: gif.composite(gifData)[0].pixels }; } else { - const image = await Image.decode(value); - images.set(href, { width: image.width, height: image.height, pixels: image.bitmap }); + const image = await Image.decode(value, { maxPixels: imagePixels }); + decoded = { width: image.width, height: image.height, pixels: image.bitmap }; } - } catch { - // (images that can't be decoded aren't drawn, like resvg's) + imagePixels -= decoded.width * decoded.height; + images.set(href, decoded); + } catch (err) { + images.set(href, { error: err instanceof LimitError ? tooLarge(err) : new Error(`SVG: the image ${shown(href)} can't be decoded`, { cause: err }) }); } } - 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)), maxPixels, maxLayerPixels); const image = new Image(width, height); image.bitmap.set(pixels); @@ -3328,74 +3086,45 @@ export class Image { } /** - * Creates a new image containing the rendered text. - * @param text Text to render - * @param options The font and size, color, layout and style, see {@link RenderTextOptions}; the image grows by the - * 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) + * Creates a new image containing the rendered text + * @example + * ```ts + * const label = await Image.renderText('Hello, world', { font: fontBytes, size: 48, color: 0xffffffff }); + * ``` * @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 + * @param options The font and size, color, layout and style, see {@link RenderTextOptions}. Without a `width` or + * `height`, the image grows by the outline width on every side; with one, it has exactly that size * @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; - const layout = options.layout instanceof TextLayout ? options.layout : new TextLayout(options.layout); + * @throws `TypeError` if the text isn't a string, or the options aren't an object with a `font` + * @throws `Error` if the font can't be read + * @throws {@link LimitError} if the image would have more than `options.maxPixels` pixels (checked before anything is + * allocated) + */ + 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 = {}, maxPixels = MAX_PIXELS } = options; + pixelLimit(maxPixels); + const { width: boxWidth, height: boxHeight, wrapStyle, horizontalAlign, verticalAlign } = options; + const layout = textLayout({ width: boxWidth, height: boxHeight, wrapStyle, horizontalAlign, verticalAlign }); const axes = textRenderer.axisValues(variations); - size = finite(size, 'font size'); - if (size < 0) throw new RangeError(`Invalid font size: ${size}`); + const size = positive(options.size, 'font size'); if (finite(strokeWidth, 'stroke width') < 0) throw new RangeError('Invalid stroke width'); const fonts = Array.isArray(font) ? font : [font]; if (!fonts.length) throw new RangeError('No font given'); const [r, g, b, a] = Colors.toRGBA(color); + // the outline needs room around the glyphs (checked against maxPixels with them): a given size is the image's, + // the text goes inside the outline + const pad = Math.ceil(strokeWidth); + if (layout.width !== undefined && layout.width <= 2 * pad) throw new RangeError(`The width (${layout.width}) leaves no room for the text inside the ${strokeWidth} pixel outline`); + if (layout.height !== undefined && layout.height <= 2 * pad) throw new RangeError(`The height (${layout.height}) leaves no room for the text inside the ${strokeWidth} pixel outline`); + if (layout.width !== undefined) layout.width -= 2 * pad; + if (layout.height !== undefined) layout.height -= 2 * pad; // color glyph layers in the text color take it without its alpha, which applies to the whole color glyph - const rendered = await textRenderer.render(fonts.map(view), size, text, layout, hinting, (r << 24 | g << 16 | b << 8 | 255) >>> 0, featureList(features), axes); + const rendered = await textRenderer.render(fonts.map(view), size, text, layout, hinting, (r << 24 | g << 16 | b << 8 | 255) >>> 0, featureList(features), axes, maxPixels, pad); - // the outline needs room around the glyphs - const pad = Math.ceil(strokeWidth); const width = rendered.width + 2 * pad, height = rendered.height + 2 * pad; const coverage = new Uint8Array(width * height); for (let y = 0; y < rendered.height; y++) @@ -3488,30 +3217,47 @@ 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'] -]); +/** + * Decoded pixels as an image's bitmap: as they are, or copied if they don't start at an offset the 32-bit pixel views + * need (the bitmap setter refuses those, so a decoder returning a view into a larger buffer doesn't fail) + * @internal + */ +export function alignedPixels(pixels: T): T { + return pixels.byteOffset % 4 ? pixels.slice() as T : pixels; +} /** - * 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]; - const { width, height: optionsHeight, ...options } = widthOrOptions; +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'); - return [width ?? ResizeModes.AUTO, optionsHeight ?? ResizeModes.AUTO, options]; + oneOf(fit, RESIZE_FITS, 'fit'); + return [width === undefined ? undefined : finite(width, 'width'), optionsHeight === undefined ? undefined : finite(optionsHeight, 'height'), rest, fit]; } -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 }; +/** + * 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)))]; } -// 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']]); +const RESIZE_MODES: readonly ResizeMode[] = ['nearest-neighbor', 'bilinear', 'bicubic', 'area']; + +// the resize options with their defaults, the mode checked before anything changes +function resizeOptions(options?: ResizeOptions): { mode: ResizeMode } { + return { mode: options?.mode === undefined ? 'bilinear' : oneOf(options.mode, RESIZE_MODES, 'resize mode') }; +} // 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)); @@ -3538,11 +3284,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); } /** diff --git a/src/index.ts b/src/index.ts index 081b70ca..c4dace2e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,52 +1,55 @@ +/** + * @packageDocumentation + * @document ../guides/Migrating to 4.0.md + */ +// (the guide is attached to the entry module: as a project document it turned the docs into a module "index", every +// page's file name prefixed with it, e.g. classes/index.Image.html instead of classes/Image.html) import { BinaryData } from './util/mem.ts'; import { Image, DecodeOptions } from './image.ts'; -import { GIF } from './gif.ts'; -import { Animation, AnimationDecodeOptions } from './animation.ts'; -import { isOptions } from './util/options.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'; import { view } from './util/mem.ts'; import { ImageType } from './image-type.ts'; -export { Image, ResizeModes, SVGScaleModes } from './image.ts'; +export { Image } 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 { Color, ColorFunction, ResizeMode, ResizeFit, TextStyleOptions, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, TGAEncodeOptions, GIFEncodeOptions, DitherMethod, DitherOptions, GainMapEmbedOptions, RenderSVGOptions, SVGResourceOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, FlipDirection, 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 { GIF } from './gif.ts'; +export type { GainMapOptions, GainMapEncodeOptions, GainMapChannelValues } 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, GIFAnimationEncodeOptions, 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'; +export { LimitError } from './util/limits.ts'; export type { ImageTypeName } from './image-type.ts'; export type { ToneMapping } from './codecs/hdr.ts'; 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 - * @param data The image data - * @param options See {@link AnimationDecodeOptions} (for animations) and {@link DecodeOptions} (for images) + * 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} */ -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 - * @param data The image data - * @param onlyExtractFirstFrame 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(input: BinaryData, options?: DecodeOptions): Promise; +export async function decode(input: BinaryData, options: DecodeOptions = {}): Promise { + checkOptions(options, 'decode(data, { maxPixels: 1e8 })'); + 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(input, options); + + const animation = await Animation.decode(input, options); + 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/src/metadata.ts b/src/metadata.ts index f2dca830..3999b82b 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -1,4 +1,3 @@ -import { VERSION } from './version.ts'; import { encode as utf8 } from './util/utf8.ts'; import * as exif from './codecs/exif.ts'; @@ -13,7 +12,11 @@ import * as exif from './codecs/exif.ts'; * | GIF | software, comment (comment extensions) | * | TGA | author, comment, creation time, software (extension area) | * - * Empty fields are left out + * The Exif and TGA text fields are ASCII in the file format; other characters are written as UTF-8, which readers + * that follow the format show wrongly. PNG text chunks take any text. + * + * Empty fields (and `null` ones) are left out. Without metadata, the files only say which software made them (`software`), so the same + * image always encodes to the same bytes */ export interface Metadata { /** The image's title */ @@ -24,10 +27,16 @@ export interface Metadata { description?: string; /** The image's copyright info */ copyright?: string; - /** The image's creation time. Default: now */ - creationTime?: string | number | Date; - /** The software used to create the image. Default: `'ImageScript vX.X.X'`, `''` leaves it out */ - software?: string; + /** + * The image's creation time + * @defaultValue None (`null` leaves it out, too) + */ + creationTime?: string | number | Date | null; + /** + * The software used to create the image + * @defaultValue `'ImageScript'` (without a version), `null` leaves it out + */ + software?: string | null; /** A disclaimer for the image */ disclaimer?: string; /** A warning for the image */ @@ -47,7 +56,7 @@ export interface ResolvedMetadata { author?: string; description?: string; copyright?: string; - creationTime: Date; + creationTime?: Date; software?: string; disclaimer?: string; warning?: string; @@ -62,13 +71,16 @@ const TEXT_FIELDS = ['title', 'author', 'description', 'copyright', 'software', * @ignore */ export function resolveMetadata(metadata: Metadata = {}): ResolvedMetadata { + if (metadata !== null && typeof metadata !== 'object') throw new TypeError(`metadata takes an object, like { title: 'Sunset' }, not ${typeof metadata}`); const { creationTime, software } = metadata || {}; - const time = new Date(creationTime === undefined ? Date.now() : creationTime); - if (isNaN(time.getTime())) throw new RangeError('Invalid creation time'); - - const resolved: ResolvedMetadata = { creationTime: time }; + const resolved: ResolvedMetadata = {}; + if (creationTime !== undefined && creationTime !== null) { + const time = new Date(creationTime); + if (isNaN(time.getTime())) throw new RangeError('Invalid creation time'); + resolved.creationTime = time; + } for (const key of TEXT_FIELDS) { - const value = key === 'software' && software === undefined ? `ImageScript v${VERSION}` : metadata?.[key]; + const value = key === 'software' && software === undefined ? 'ImageScript' : metadata?.[key]; if (value) resolved[key] = String(value); } return resolved; @@ -84,7 +96,7 @@ export function pngText(m: ResolvedMetadata): Record Author: m.author, Description: m.description, Copyright: m.copyright, - 'Creation Time': m.creationTime.toUTCString(), + 'Creation Time': m.creationTime?.toUTCString(), Software: m.software, Disclaimer: m.disclaimer, Warning: m.warning, diff --git a/src/render/colr.ts b/src/render/colr.ts index 151a2da1..bfdf509e 100644 --- a/src/render/colr.ts +++ b/src/render/colr.ts @@ -523,7 +523,7 @@ function composite(mode: number, source: Layer, backdrop: Layer): Layer { else src.fill(0); const as = src[3], ab = d[o + 3]; // Porter-Duff: result = source * fa + backdrop * fb - let fa = 0, fb = 0; + let fa: number, fb: number; switch (mode) { case 0: fa = 0; fb = 0; break; case 1: fa = 1; fb = 0; break; diff --git a/src/render/font.ts b/src/render/font.ts index dd7355d3..09540986 100644 --- a/src/render/font.ts +++ b/src/render/font.ts @@ -377,7 +377,7 @@ export class Font { private components(offset: number): { components: Array<{ glyph: number, flags: number, arg1: number, arg2: number, matrix: [number, number, number, number] }>, end: number } { const components = []; const f2dot14 = (o: number): number => this.i16(o) / 16384; - let p = offset + 10, flags = 0; + let p = offset + 10, flags: number; do { flags = this.u16(p); const glyph = this.u16(p + 2); diff --git a/src/render/layout.ts b/src/render/layout.ts index 52251440..496d42fc 100644 --- a/src/render/layout.ts +++ b/src/render/layout.ts @@ -1123,7 +1123,7 @@ export class LayoutTable { if (!isMarkGlyph(info[it.idx])) return false; const j = it.idx; const id1 = ligId(buffer.cur()), id2 = ligId(info[j]), comp1 = ligComp(buffer.cur()), comp2 = ligComp(info[j]); - let good = false; + let good: boolean; if (id1 === id2) good = id1 === 0 || comp1 === comp2; else good = id1 > 0 && !comp1 || id2 > 0 && !comp2; if (!good) return false; diff --git a/src/render/segment.ts b/src/render/segment.ts index 75c2f89c..8eba3506 100644 --- a/src/render/segment.ts +++ b/src/render/segment.ts @@ -17,7 +17,7 @@ export function graphemeBoundaries(codepoints: ArrayLike): Uint8Array { pictographic[i] = valueIndex(extendedPictographic, codepoints[i]) ? 1 : 0; } // GB11: whether an Extended_Pictographic Extend* is before, and with ZWJ; GB12/13: the regional indicators before - let emoji = false, emojiZWJ = false, ri = 0; + let emoji = false, emojiZWJ: boolean, ri = 0; // GB9c: whether a linker is before (with InCB=Extend characters after it) let linker = false; for (let i = 1; i < n; i++) { diff --git a/src/render/shapers/hangul.ts b/src/render/shapers/hangul.ts index 4ada0b79..66e858c3 100644 --- a/src/render/shapers/hangul.ts +++ b/src/render/shapers/hangul.ts @@ -112,7 +112,7 @@ function preprocessText(_plan: Plan, buffer: Buffer, font: ShapeFont): void { let i = start; info[i++].category = Jamo.LJMO; info[i++].category = Jamo.VJMO; - if (i < end) info[i++].category = Jamo.TJMO; + if (i < end) info[i].category = Jamo.TJMO; buffer.mergeOutClusters(start, end); continue; } diff --git a/src/render/svg.ts b/src/render/svg.ts index 5e421eb5..8384380e 100644 --- a/src/render/svg.ts +++ b/src/render/svg.ts @@ -4,7 +4,8 @@ *