Skip to content

Commit 6291ef1

Browse files
committed
rework docs
1 parent ff82f62 commit 6291ef1

7 files changed

Lines changed: 159 additions & 154 deletions

File tree

‎README.md‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@
1313

1414
---
1515

16-
**ImageScript** decodes, edits and encodes images in pure TypeScript: no WebAssembly, no native binaries, no
17-
dependencies. It runs in Node.js 22.13+, Deno, Bun, browsers and edge runtimes (Cloudflare Workers, Vercel's Edge
18-
Runtime, Deno Deploy), works with both `import` and `require()`, and ships with type definitions. Large images are
19-
encoded and decoded on several threads wherever the runtime supports workers.
16+
**ImageScript** decodes, edits and encodes images in pure TypeScript: no WebAssembly, no native binaries, and no
17+
runtime dependencies. It runs in Node.js 22.13+, Deno, Bun, browsers and edge runtimes (Cloudflare Workers, Vercel's
18+
Edge Runtime, Deno Deploy), works with `import` and, on supported Node.js versions, `require()`, and ships with type
19+
definitions. Large AVIF and JPEG XL operations, and lossless WebP encoding, can use multiple CPU cores when the runtime
20+
supports it.
2021

2122
```sh
2223
npm install imagescript # Node.js, Bun and bundlers
@@ -29,7 +30,7 @@ Without a bundler, import it from jsDelivr in the browser:
2930

3031
```html
3132
<script type="module">
32-
import { Image } from 'https://cdn.jsdelivr.net/npm/imagescript@3/+esm';
33+
import { Image } from 'https://cdn.jsdelivr.net/npm/imagescript@4/+esm';
3334
3435
const image = await Image.decode(await fetch('photo.jpg').then(r => r.arrayBuffer()));
3536
image.resize({ width: 256 });
@@ -73,17 +74,23 @@ await writeFile('output.png', await image.encodePNG());
7374
| GIF, APNG (animations) | ✓ | ✓ |
7475
| BMP, QOI, TGA, Netpbm | ✓ | ✓ |
7576
| TIFF, ICO/CUR, Radiance HDR | ✓ | |
76-
| [HDR gain maps](https://imagescript.dev/classes/GainMap.html) (Ultra HDR and iPhone JPEGs, AVIF, JPEG XL) | ✓ | ✓, kept through edits |
77+
| [HDR gain maps](https://imagescript.dev/classes/GainMap.html) (brighter highlights on HDR displays; JPEG, AVIF, JPEG XL) | ✓ | ✓ |
7778

7879
[Animations](https://imagescript.dev/classes/Animation.html) can be decoded from GIF, APNG, WebP and JPEG XL,
7980
and encoded as GIF, APNG or WebP, all at once or
80-
[frame by frame](https://imagescript.dev/classes/Animation.html#webpencoder). Embedded color profiles (Display
81-
P3, Adobe RGB, ...) are converted to sRGB when decoding, so images look the way browsers show them.
81+
[frame by frame](https://imagescript.dev/classes/Animation.html#webpencoder). Lossless encoding keeps every pixel
82+
exactly; lossy encoding makes smaller files by approximating some image detail.
8283

83-
- [Text rendering](https://imagescript.dev/classes/Image.html#rendertext) with TrueType, OpenType and WOFF fonts:
84-
all scripts, color emoji, variable fonts
84+
Embedded color profiles are converted to sRGB, the standard color format for most screens, where supported. CMYK and
85+
lookup-table profiles are left unchanged.
86+
87+
Supported image operations preserve gain maps; editing pixels directly with `image.bitmap` or `setPixelAt()` does not.
88+
89+
- [Text rendering](https://imagescript.dev/classes/Image.html#rendertext) with TrueType, OpenType and WOFF fonts
90+
(not WOFF2; some OpenType outline formats are unsupported): right-to-left text and scripts where letters connect or
91+
combine, plus color emoji and variable TrueType fonts
8592
- [SVG rendering](https://imagescript.dev/classes/Image.html#rendersvg), including text (with fonts you pass
86-
or fonts embedded in the SVG)
93+
or fonts embedded in the SVG); no automatic fetching of external images or fonts
8794
- [Resizing](https://imagescript.dev/classes/Image.html#resize),
8895
[cropping](https://imagescript.dev/classes/Image.html#crop),
8996
[rotating](https://imagescript.dev/classes/Image.html#rotate),

‎bench/README.md‎

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
# ImageScript benchmark
22

3-
How [ImageScript](https://github.com/imagescript/ImageScript) compares to other image libraries for Node.js: speed, file
4-
sizes and what installing each takes. ImageScript is plain JavaScript with no dependencies, so it runs wherever
5-
JavaScript does, like Jimp, image-js and PureJsImage; sharp and @napi-rs/canvas wrap native libraries (libvips, Skia),
6-
Photon is WebAssembly, and so is jsquash (Squoosh's libavif and libjxl, for AVIF and JPEG XL).
3+
This benchmark compares processing speed, output-file size and install size. Lower times and smaller files are better,
4+
but compare lossy files alongside their quality scores. Results depend on the machine, so compare libraries within a
5+
single run. The Method section explains the test inputs and measurements.
76

87
<img src="charts/overview-portable.svg" alt="Time per operation of the libraries that run everywhere, colored by the distance to the fastest">
98

@@ -53,7 +52,8 @@ Time per operation (less is faster), measured 2026-11-08 with Node.js v24.22.2 o
5352
| render SVG | **172 ms** | – | – | – | – | 43 ms | – | 19 ms |
5453
| thumbnail, keeping HDR | **180 ms** | – | – | – | – | 167 ms | – | – |
5554

56-
Encoded sizes:
55+
Encoded sizes. For lossy files, the dB score estimates how closely the decoded image matches the source; higher is
56+
closer.
5757

5858
| | **ImageScript** | **Jimp** | **image-js** | **PureJsImage** | **Photon** | **sharp** | **jsquash** | **@napi-rs/canvas** |
5959
|---|--:|--:|--:|--:|--:|--:|--:|--:|
@@ -87,10 +87,11 @@ Encoded sizes:
8787
when the benchmark starts, PNG, lossy WebP, AVIF by sharp (libaom) and JPEG XL by jsquash (libjxl), at the
8888
qualities below), a 1434×1434 graphic with transparency (PNG), a 445×445 diagram with transparency placed on
8989
the photo (PNG), a 300×300 GIF with 101 frames (and made from it, an animated WebP by sharp at quality 80).
90-
- Lossy WebP, AVIF and JPEG XL are encoded at the same nominal quality (80, 60 and 90); as the libraries'
91-
scales differ, the file sizes come with their PSNR (of the color over black, decoded by libwebp, libavif and
92-
libjxl). jsquash's libjxl runs at the effort ImageScript's default matches (cjxl's 5 lossy, 3 lossless; its own
93-
default is 7). PureJsImage's AVIF
90+
- Lossy WebP, AVIF and JPEG XL are encoded at the same nominal quality (80, 60 and 90). Because each library uses a
91+
different quality scale, file sizes are shown with PSNR (peak signal-to-noise ratio), a measure of how closely the
92+
decoded colors match the original; higher is closer. Colors are compared over black after decoding with libwebp,
93+
libavif and libjxl. jsquash's JPEG XL encoder uses effort settings chosen to match ImageScript's defaults.
94+
PureJsImage's AVIF
9495
encoder has no quality option, and its JPEG XL decoder doesn't support everything libjxl's files use, which shows as
9596
failing. jsquash only encodes and decodes AVIF and JPEG XL (it gets the other inputs as pixels); @napi-rs/canvas
9697
decodes both with Skia's codecs, but doesn't encode them.
@@ -101,17 +102,18 @@ Encoded sizes:
101102
clone first, sharp runs a pipeline from raw pixels back to raw pixels, PureJsImage (which has no decoded images) one
102103
from a BMP back to a BMP, @napi-rs/canvas draws into a new canvas. PureJsImage has no blur, composite, rendering or
103104
animated GIFs.
104-
- Where the library lets you choose, resizing is bilinear (or bicubic, in its own row), the thumbnail covers 400×400
105-
(cropping the sides), blurring is gaussian with a radius or sigma of 5, sharpening an unsharp mask of radius or
106-
sigma 1 (Photon: its 3×3 sharpening kernel), rotating by 30° grows the image to fit, brightness and contrast go up
105+
- Where the library lets you choose, resizing is bilinear (smooth) or bicubic (a sharper smoothing filter, in its own
106+
row), the thumbnail covers 400×400 (cropping the sides), blurring is gaussian (softening) with a radius or sigma of
107+
5, sharpening uses an unsharp mask (edge enhancement) of radius or sigma 1 (Photon: its 3×3 sharpening kernel),
108+
rotating by 30° grows the image to fit, brightness and contrast go up
107109
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
108110
otherwise, e.g. its PNG compression level.
109111
- Threads: ImageScript uses worker threads where they pay off (the processors available, at most 8), and sharp gets
110112
as many (`sharp.concurrency()`; its default on glibc Linux without jemalloc, like on CI, is one). The other
111113
libraries run on one thread (jsquash turns its threaded builds off in Node).
112114
- [tinybench](https://github.com/tinylibs/tinybench), at least 1.5 seconds and 3 runs per measurement, every library
113115
in its own process.
114-
- The release workflow runs the benchmark for every release and commits the results, on a GitHub Actions runner;
116+
- The release workflow runs the benchmark for every regular release and commits the results, on a GitHub Actions runner;
115117
runners vary, so compare libraries within one run rather than between runs.
116118
- Install sizes: `npm install <library>` in an empty project, the apparent size of `node_modules`.
117119

‎guides/Migrating to 4.0.md‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -158,19 +158,22 @@ Search your code for: `.rotate(`.
158158

159159
### JPEG composites transparent pixels onto black
160160

161-
JPEG has no alpha channel. In 3.x, `encodeJPEG()` dropped the alpha and kept the color, so a fully transparent pixel
162-
showed whatever RGB it had stored (often black, but not always). In 4.0, an image with transparent or translucent pixels
163-
is composited onto a background first, black by default, the way a viewer would show it on black. Pass `background`
164-
for another color: `image.encodeJPEG({ background: 0xffffffff })`. Opaque images encode as before, byte for byte.
165-
The new `subsampling` option can select `'4:4:4'` or `'4:2:0'`; its default remains 4:2:0 up to quality 90 and 4:4:4
166-
above.
161+
JPEG does not store transparency (also called an alpha channel). In 3.x, `encodeJPEG()` dropped the alpha and kept the
162+
color, so a fully transparent pixel showed its stored red, green and blue values (often black, but not always). In
163+
4.0, an image with transparent or translucent pixels is combined with a background first, black by default, the way
164+
a viewer would show it on black. Pass `background` for another color: `image.encodeJPEG({ background: 0xffffffff })`.
165+
Opaque images encode as before, byte for byte.
166+
The new `subsampling` option controls how much color detail to keep: `'4:4:4'` keeps it all; `'4:2:0'` stores less
167+
color detail to make a smaller file. Its default remains 4:2:0 up to quality 90 and 4:4:4 above.
167168

168169
Search your code for: `encodeJPEG(` on images that can have transparency.
169170

170171
### `quantize()` dithers by default
171172

172-
`image.quantize()` now dithers with Floyd-Steinberg at a strength of `0.85` by default, like the GIF encoders. In 3.x
173-
it didn't dither unless you passed `dither`. For the old result, pass `dither: 'none'`:
173+
`image.quantize()` reduces an image to a limited color palette. It now uses dithering by default: a pattern of small
174+
color changes that helps smooth the result when colors are reduced. The default method is Floyd-Steinberg, with a
175+
strength of `0.85`, like the GIF encoders. In 3.x it didn't dither unless you passed `dither`. For the old result,
176+
pass `dither: 'none'`:
174177

175178
| 3.x | 4.0 |
176179
| --- | --- |
@@ -279,8 +282,9 @@ image.red(0.5, true);
279282
for (let i = 0; i < image.bitmap.length; i += 4) image.bitmap[i] = 0.5 * 255;
280283
```
281284

282-
`bitmap` is RGBA, four bytes per pixel. Writing it directly skips the gain map of an HDR image, so do not use it on
283-
images that have one. `absolute` on `lightness()` and `saturation()` sets the value of every pixel and keeps its hue.
285+
`bitmap` stores four bytes per pixel: red, green, blue and transparency (alpha). Writing it directly skips the gain map
286+
of a high-dynamic-range (HDR) image, so do not use it on images that have one. `absolute` on `lightness()` and
287+
`saturation()` sets the value of every pixel and keeps its hue.
284288
Do it per pixel: read the color with `Colors.toHSLA()`, set the lightness or saturation, and write the pixel back with
285289
`Colors.fromHSLA()`.
286290

‎src/animation.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,16 +60,18 @@ export interface AnimationEncodeOptions extends EncodeOptions {
6060
/** Options of {@link Animation.encodeWebP} */
6161
export interface WebPAnimationEncodeOptions extends AnimationEncodeOptions {
6262
/**
63-
* Lossy frames (0-100, like libwebp's quality: 75 is its default), much smaller than lossless ones. Without it the
64-
* frames are lossless. Optimized lossy frames, like libwebp's, leave out 8x8 blocks that only changed a little (by
65-
* up to 3 per channel at quality 85, 1 at 100)
63+
* Lossy compression quality (0-100): higher values usually preserve more detail but make larger files. Without
64+
* this option, frames are encoded losslessly
6665
*/
6766
quality?: number;
68-
/** How much effort the frames take (0-6, lossy and lossless), see {@link WebPEncodeOptions.effort}. Default: `2` */
67+
/**
68+
* Compression effort (0-6): higher effort can make smaller files but takes longer, see
69+
* {@link WebPEncodeOptions.effort}. Default: `2`
70+
*/
6971
effort?: number;
70-
/** Lossy frames: libwebp's sharp YUV conversion, see {@link WebPEncodeOptions.sharpYuv}. Default: `false` */
72+
/** For lossy frames, improve color detail around sharp edges. Default: `false` */
7173
sharpYuv?: boolean;
72-
/** Lossy frames: the alpha planes' quality (0-100), see {@link WebPEncodeOptions.alphaQuality}. Default: `100` */
74+
/** For lossy frames, the quality of the transparency channel (0-100). Default: `100` */
7375
alphaQuality?: number;
7476
}
7577

‎src/gainmap.ts‎

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,24 @@ export type GainMapChannelValues = number | readonly [red: number, green: number
1515

1616
/** Options of the {@link GainMap} constructor */
1717
export interface GainMapOptions {
18-
/** log2 of the boost of the map's value 0 (`-1`: half as bright; hdrgm's GainMapMin). Default: `0` */
18+
/** Extra brightness at the map's darkest value (0); `-1` means half as bright. Default: `0` (no change) */
1919
min?: GainMapChannelValues;
20-
/** log2 of the boost of the map's value 255 (`2`: 4 times as bright; hdrgm's GainMapMax). Default: `2` */
20+
/** Extra brightness at the map's brightest value (255); `2` means four times as bright. Default: `2` */
2121
max?: GainMapChannelValues;
22-
/** the gamma the map's values are stored with. Default: `1` */
22+
/** How quickly the brightness changes between `min` and `max`; `1` changes it evenly. Default: `1` */
2323
gamma?: GainMapChannelValues;
24-
/** added to the SDR image's linear values before the boost (keeps black from being boosted forever). Default: `1/64` */
24+
/**
25+
* Small brightness offset added before applying the boost, to keep black from being boosted indefinitely.
26+
* Default: `1/64`
27+
*/
2528
offsetSDR?: GainMapChannelValues;
26-
/** subtracted from the HDR result. Default: `1/64` */
29+
/** Small brightness offset subtracted from the result. Default: `1/64` */
2730
offsetHDR?: GainMapChannelValues;
28-
/** log2 of the display headroom from which on the map starts to apply. Default: `0` */
31+
/** How bright a display must be before the map starts to apply; `0` is standard-screen brightness. Default: `0` */
2932
hdrCapacityMin?: number;
30-
/** log2 of the display headroom at which it applies fully. Default: `max`'s largest value */
33+
/** How bright a display must be for the map to apply fully. Default: the largest value in `max` */
3134
hdrCapacityMax?: number;
32-
/** whether the boost applies in the SDR image's color space (`false`: in the HDR image's). Default: `true` */
35+
/** Whether the boost uses the original image's colors (`false`: the brighter HDR colors). Default: `true` */
3336
useBaseColorSpace?: boolean;
3437
}
3538

@@ -41,13 +44,14 @@ const perChannel = (value: GainMapChannelValues, name: string): number[] => {
4144
};
4245

4346
/**
44-
* A gain map: a small image saying how much brighter each part of an image gets on an HDR display, as Ultra HDR
45-
* (JPEG-R) photos from phones carry it. HDR displays show the image boosted by it, the others the image as it is.
47+
* A gain map is a small companion image that tells a high-dynamic-range (HDR) display how much brighter to show each
48+
* part of a photo. Standard displays ignore it and show the regular-brightness image.
4649
*
47-
* {@link Image.decode} reads the gain maps of JPEGs, AVIFs and JPEG XLs into {@link Image.gainMap},
48-
* {@link Image.encodeJPEG}, {@link Image.encodeAVIF} and {@link Image.encodeJXL} write them. The map covers the whole image, at any resolution (phones
49-
* write a quarter of the width and height). Its value `v` (0-255, per channel or one for all) boosts the linear
50-
* light of the image by `2^(min + (max - min) * (v / 255)^(1 / gamma))`.
50+
* {@link Image.decode} reads gain maps from supported JPEG, AVIF and JPEG XL images into {@link Image.gainMap};
51+
* {@link Image.encodeJPEG}, {@link Image.encodeAVIF} and {@link Image.encodeJXL} can write them. The map covers the
52+
* whole image, at any resolution. Each pixel's value (0-255) controls how much extra brightness to add at that
53+
* location. For developers who need the exact calculation, the boost is
54+
* `2^(min + (max - min) * (v / 255)^(1 / gamma))`, per color channel.
5155
*
5256
* 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)
5357
* throws, and the map keeps its value

0 commit comments

Comments
 (0)