Skip to content

Releases

3.8.0

Choose a tag to compare

@matmen matmen released this 28 Oct 11:17
Immutable release. Only release title and notes can be modified.

This release encodes animations a frame at a time, adds OpenType features to text rendering and sharp YUV conversion to lossy WebP, and brings SVG rendering much closer to resvg: filters, patterns, markers and embedded images. It also fixes lossless WebPs that 3.7.0 could write undecodable (#82). Like every codec in ImageScript, it's plain TypeScript: no WebAssembly and no native code. There are no breaking API changes.

const encoder = Animation.webpEncoder(1000, 1000, { quality: 85 });
for (const frame of frames) await encoder.add(frame, { duration: 40 });
const webp = await encoder.finish();

await Image.renderText(font, 32, 'Office 1/2', { features: { smcp: true, frac: true } });
await image.encodeWebP({ quality: 80, sharpYuv: true });
await Image.renderSVG(svg); // now with filters, patterns, markers and <image>

New

Animations a frame at a time

Animation.webpEncoder(), Animation.gifEncoder() and Animation.apngEncoder() (width, height, options) encode animations without holding all frames in memory: await encoder.add(frame, options) for each frame (a Frame or any image; options like { duration: 40 } override the frame's), then await encoder.finish().

  • Same files as encodeWebP(), encodeGIF() and encodeAPNG() produce for the same frames
  • Memory: only the canvas and the previous frame are kept (APNG keeps its frames compressed until the end): 120 frames of 1000x1000 take 159 MB instead of 1066 MB

OpenType features in renderText

features: { tnum: true, smcp: true, liga: false, salt: 2 } turns features on for the whole text (tabular numbers, small caps, stylistic sets, ...), turns them off (default ones like ligatures and kerning included) or picks an alternate by number. It works like HarfBuzz's user features and shapes exactly like hb-shape --features. Fonts that lack a feature ignore it.

Sharp YUV for lossy WebP

encodeWebP({ quality, sharpYuv: true }) (for images, animations and Animation.webpEncoder) converts to YUV with libwebp's sharp conversion, ported so the files match cwebp -sharp_yuv. Colored edges stay sharp. Encoding takes about twice as long. Photos come out about the same size (+0.3% on a 1920x1080 photo), but images with many hard color edges get considerably larger.

Image#region()

region(x, y, width, height) copies a region into a new image without changing the original. It's like crop on a clone, but without copying the whole image first.

SVG rendering

  • Filters: all 17 primitives (blur, offset, flood, composite, merge, drop shadow, color matrix, component transfer, tile, blend with all 16 modes, morphology, convolve matrix, turbulence, displacement map, diffuse and specular lighting with all three lights, image), plus the CSS filter functions (blur(), drop-shadow(), grayscale(), ...). Filter regions, color-interpolation-filters and href inheritance are supported. Ported from resvg: 396 of its 408 filter tests pass, up from 81
  • Patterns: in both units, with viewBox, preserveAspectRatio, patternTransform and href inheritance. 32 of resvg's 33 pattern tests pass, up from 4
  • Markers: start, mid and end markers, with orient (including auto-start-reverse), markerUnits, refX/refY, viewBox and clipping, placed like resvg places them. 61 of 64 tests pass, up from 11
  • Images (<image>) from data URIs: every format Image.decode() reads (GIFs show their first frame) and nested SVGs, gzipped ones included. External files and URLs aren't loaded

Text in SVGs isn't rendered yet.

Improvements

  • Optimized lossy WebP animations are smaller and look better. Like libwebp, they skip pixels that changed by less than a tolerance derived from the quality, and make unchanged pixels transparent only in whole 8x8 blocks. On cross-fading animations the files are up to 13% smaller than before, at 0.1 dB PSNR below unoptimized ones (before: 0.8-1 dB below)

Fixes

  • Lossless WebPs, and lossy ones with alpha (most optimized animations), could be undecodable since 3.7.0 (#82)

Performance

  • composite() with an opacity is about 40% faster in normal mode (7.6 ms instead of 12.6 ms for a 1100x680 layer), and 5-30% faster without an opacity

3.7.0

Choose a tag to compare

@matmen matmen released this 26 Oct 07:24
Immutable release. Only release title and notes can be modified.

This release encodes lossy JPEG XLs, makes lossless WebPs and the alpha of lossy ones smaller, and decodes lossy JPEG XLs about 15% faster. Like every codec in ImageScript, it's plain TypeScript: no WebAssembly and no native code. There are no breaking API changes.

await image.encodeJXL();                          // lossless, as before
await image.encodeJXL({ quality: 90 });           // lossy, like cjxl -q 90
await image.encodeJXL({ quality: 75, effort: 2 });

New

Lossy JPEG XL encoding

encodeJXL({ quality, effort }) encodes lossy JPEG XLs (VarDCT) when given a quality. Without it, images are encoded losslessly as before.

  • quality (0-100) maps to the Butteraugli distance like cjxl -q: 90 is cjxl's default, 100 is lossless
  • effort (0-2, default 1): 1 chooses the transforms' sizes like libjxl's effort 5 (from 4x4 up to 64x64, chroma from luma per tile), 2 also tries the 4x8 and AFV transforms, 0 uses 8x8 DCTs only (like effort 3)
  • Alpha is lossless
  • Compression: at the same SSIMULACRA2 score the files are about 1.5% smaller than cjxl -e 5's at effort 1 (3.5% at effort 2), and within 1-4% of cjxl -e 7's, the default (up to 9% for screenshots)
  • Speed: a 1920x1080 photo takes about 1 s at effort 0, 5 s at effort 1, 10 s at effort 2

Improvements

  • Lossless WebP files are about 6% smaller (within about 7% of cwebp's), the alpha of lossy WebPs about 17% (at the default effort smaller than cwebp's): blocks are grouped by their statistics with prefix codes per group, the color cache's size is chosen, matches look one pixel ahead, smooth alpha is predicted instead of indexed. Lossless encoding takes about 65% longer

Performance

  • JPEG XL decoding of lossy images is about 15% faster (2048x1536: 0.73 s instead of 0.87 s), with identical results
  • AVIF encoding is about 8% faster, with files about as large as before

Fixes

  • AVIFEncodeOptions is exported, with the new JXLEncodeOptions

3.6.0

Choose a tag to compare

@matmen matmen released this 25 Oct 19:44
Immutable release. Only release title and notes can be modified.

This release writes metadata into AVIFs and JPEG XLs, makes PNGs of graphics up to 30% smaller and lossy WebP encoding about four times faster by default. There are no breaking API changes.

await image.encodeAVIF({ metadata: { author: 'me', description: 'a cat' } });
await image.encodeJXL({ metadata: { software: '' } });   // without the software tag
await image.encodeWebP({ quality: 75 });                 // effort 2 now, about 250 ms for 1920x1080
await image.encodeWebP({ quality: 75, effort: 4 });      // like cwebp's default, as before

New

Metadata in AVIF and JPEG XL

encodeAVIF() and encodeJXL() take metadata like the other encoders: author, description, copyright, creation time and software (by default ImageScript and its version) as Exif.

  • AVIF: an Exif item that describes the image, like libavif writes it
  • JPEG XL: the codestream goes into the container, with an Exif box. The codestream is still byte for byte what cjxl -e 1 writes. As the creation time is always written, JPEG XLs are now always in the container (about 250 bytes more), like JPEGs and WebPs always have Exif

Improvements

  • PNG (and APNG): rows are left unfiltered when that compresses better than the paeth filter, as it often does for graphics and screenshots: their files are up to 30% smaller, and encoded faster. Photos are encoded as before, about 8% slower
  • Lossy WebP defaults to effort: 2 instead of 4: about a quarter of the time (about 250 ms instead of 900 ms for a 1920×1080 photo) for files about 5% larger, at a slightly lower quality (about 0.5 dB PSNR). effort: 4 still encodes byte for byte like cwebp's default
  • AVIF encoding also splits blocks into halves where that pays off: files about 2% smaller at the default effort, about 20% slower. effort: 2 also chooses smaller transforms within blocks: about 1% smaller (up to 4% for graphics)

Performance

  • Lossy WebP with effort 3 to 6 is about 25% faster, with byte for byte the same files

v3.5.0: AVIF and JPEG XL encoding

Choose a tag to compare

@matmen matmen released this 25 Oct 13:45
Immutable release. Only release title and notes can be modified.

This release encodes AVIF and lossless JPEG XL, and decodes JPEG XL about 25% faster. Like every codec in ImageScript, both encoders are plain TypeScript: no WebAssembly and no native code. There are no breaking API changes.

await image.encodeAVIF();                                   // quality 60, like avifenc -q 60
await image.encodeAVIF({ quality: 80, subsampling: '4:2:0' });
await image.encodeAVIF({ quality: 100 });                   // lossless
await image.encodeJXL();                                    // lossless, like cjxl -e 1

New

AVIF encoding

encodeAVIF({ quality, alphaQuality, subsampling, effort }) encodes AVIFs with an AV1 encoder of its own. Its syntax mirrors the dav1d based decoder, so dav1d decodes the files to exactly the encoder's reconstruction.

  • Rate-distortion search: partitions from 64×64 to 8×8, the intra modes with their angle deltas, chroma from luma, palettes and the transform types, coefficients quantized by rate and distortion, and the deblocking level and CDEF strengths chosen by the filtered image
  • Screenshots and graphics get palettes: such images are often 10% to nearly 50% smaller
  • Greyscale images are encoded as monochrome
  • Transparency becomes a monochrome alpha image, lossless by default (alphaQuality). In lossy images, fully transparent pixels get smooth colors that cost fewer bits
  • quality (0-100, default 60) maps to the quantizer like avifenc's -q. 100 is lossless
  • subsampling: '4:4:4' (default) or '4:2:0'
  • effort (0-2, default 1): how many modes and partitions are tried. 0 is about a third faster, 2 takes about three times as long for files 1-3% smaller

On photos the files are within a few percent of libaom's (avifenc's default speed) at the same PSNR. Encoding is slower, about 3 seconds for a 1024×768 photo at the default effort.

JPEG XL encoding

encodeJXL() encodes lossless JPEG XLs with a port of libjxl's fast lossless encoder. The codestream is byte for byte what cjxl -e 1 writes on CPUs with AVX2 or NEON (libjxl's output depends on the CPU). Images of at most 511 colors besides black get a palette, others YCoCg. Greyscale images are encoded as one channel and alpha only if the image has transparency. Unlike libjxl, two colors whose hashes collide within 8 pixels don't end up as one: such images are encoded with YCoCg instead.

Performance

  • JPEG XL decoding is about 25% faster: lossy 2048×1536 images take about 0.87 s instead of 1.2 s, lossless ones of the default effort 2.6 s instead of 3.7 s. The output is the same, except for rounding: linear light becomes sRGB like libjxl does it.

The encoders grow the package from 1.8 MB to 1.9 MB unpacked (506 kB packed).

v3.4.0: AVIF, JPEG XL, lossy WebP

Choose a tag to compare

@matmen matmen released this 25 Oct 06:39
Immutable release. Only release title and notes can be modified.

This release reads AVIF and JPEG XL, encodes lossy WebP and adds BMP, ICO/CUR, QOI, Netpbm and Radiance HDR. Every codec is plain TypeScript: no WebAssembly and no native code. There are no breaking API changes.

New

AVIF

Image.decode reads AVIFs through an AV1 decoder ported from dav1d. It decodes exactly like dav1d, and the conversion to RGB is libavif's, so the pixels are the ones libavif gives.

  • 4:2:0, 4:2:2, 4:4:4 and monochrome, 8, 10 and 12 bits
  • Every intra coding tool and post filter: palettes, intra block copy, filter intra, chroma from luma, lossless, tiles, deblocking, CDEF, superres, loop restoration and film grain
  • The container: grids, alpha (also premultiplied), crop, rotation and mirroring
  • HDR (PQ and HLG, BT.2020 and Display P3) is converted to sRGB, see toneMapping

Image sequences give their first frame, also from Animation.decode. Frames that reference other frames (inter frames) aren't decoded.

JPEG XL

Image.decode reads JPEG XLs and Animation.decode their animations, with durations and loop count. The decoder is ported from jxl-oxide and libjxl and supports every coding tool:

  • Modular and VarDCT, lossless and lossy, including recompressed JPEGs
  • Progressive passes and LF frames, patches, splines, noise, Gaborish and the edge-preserving filter, upsampling
  • Layers: cropped and blended frames and reference frames
  • Alpha (also premultiplied), spot colors, CMYK and the orientation

Every file of the JPEG XL conformance suite decodes within 1 of libjxl's djxl. XYB images are rendered to sRGB and HDR ones are tone mapped. ICC profiles aren't applied, so CMYK images become their colors times black, like CMYK JPEGs.

decoding (Node, one thread) lossy (VarDCT) lossless (-e 1) lossless (-e 7)
1024×1024 0.46 s 0.25 s 1.2 s
4000×3000 4.8 s 2.6 s 11.5 s

Lossy WebP

encodeWebP({ quality }) encodes lossy WebPs. The encoder is ported from libwebp and writes the same VP8 bitstream, byte for byte, as cwebp with the same quality. Transparent images get a losslessly compressed alpha plane.

await image.encodeWebP({ quality: 80 });              // like cwebp -q 80
await image.encodeWebP({ quality: 80, effort: 0 });   // like cwebp -q 80 -m 0: several times faster
await animation.encodeWebP({ quality: 80 });          // lossy frames

effort (0-6, default 4) is cwebp's -m. Without a quality, WebPs stay lossless.

Small formats

  • BMP: 1 to 64 bits, channel masks of any size, alpha, RLE4 and RLE8, embedded JPEGs and PNGs, and OS/2 headers. The output matches BMP Suite's reference images.
  • ICO and CUR: the largest image, as a bitmap with its mask or as a PNG.
  • QOI
  • Netpbm: PBM, PGM, PPM (plain and raw, up to 16 bits), PAM and PFM.
  • Radiance HDR: RGBE and XYZE, every orientation.

Image.encodeBMP, Image.encodeQOI and Image.encodePNM write them. The QOI encoder's output is byte for byte the reference encoder's. encodePNM picks PBM, PGM, PPM or PAM by what the image needs.

ImageType recognizes all new formats: 'avif', 'jxl', 'bmp', 'ico', 'cur', 'qoi', 'pnm' and 'hdr'.

HDR and toneMapping

Radiance HDRs, PFMs and HDR AVIFs and JPEG XLs can be brighter than white. By default ('reinhard'), the brightest parts are compressed like Reinhard's tone mapping operator does and the brightest pixel becomes white. Images no brighter than white stay as they are. {toneMapping: 'clamp'} clips instead.

Fixes

  • TrueType hinting matches FreeType in more fonts. SPVTL, SFVTL and SDPVTL pointed their vectors the wrong way, S45ROUND now rounds to FreeType's 45° grid, and ISECT and ALIGNPTS compute like FreeType's integer arithmetic.
  • rand alternates: the randomly picked alternates are now the ones HarfBuzz picks.
  • SVG presentation attributes: spaces around their values (fill=" url(#gradient) ") are ignored, as in CSS.

The new codecs grow the package from 1.1 MB to 1.8 MB unpacked (472 kB packed).

v3.3.0: text in every script, color emoji, faster resizing

Choose a tag to compare

@matmen matmen released this 17 Oct 22:05
Immutable release. Only release title and notes can be modified.

This release renders text in every script and color emoji, makes resizing and rotating a lot faster and hardens decoding and rendering against hostile input. There are no breaking API changes, but some results differ slightly from 3.2 (see Upgrading).

New

Text in every script

Image.renderText now shapes text like browsers do, through a port of HarfBuzz's OpenType shaping. On the tested texts, it produces the same glyphs and positions as HarfBuzz 14.6.

  • Right to left and bidirectional text with the Unicode Bidirectional Algorithm: Arabic, Hebrew and others, mixed with left to right text and numbers, with mirrored brackets.
  • Joining scripts: Arabic, Syriac, N'Ko, Mongolian, Adlam and others. Fonts without shaping features get presentation forms.
  • Indic scripts: Devanagari, Bengali, Tamil, Telugu, Malayalam and the others, with conjuncts, reph and matra reordering.
  • Other complex scripts: Khmer, Myanmar, Thai, Lao, Hangul, and the scripts of the Universal Shaping Engine, such as Sinhala, Tibetan, Javanese and Balinese.
  • All GSUB and GPOS lookups: mark positioning, cursive attachment, kerning, and contextual substitutions and positioning. Fonts without GPOS kerning use their kern table.
  • Line breaking follows the Unicode Line Breaking Algorithm, so text also wraps in CJK and around punctuation, not only at spaces.

The Unicode 18.0 data and the shaping add about 80 kB (gzipped) to the package. Nothing is loaded at runtime: no WebAssembly and no native code.

Color emoji and fallback fonts

renderText takes several fonts in order of priority:

Image.renderText([textFont, emojiFont], 32, 'Hello 👋🏽 مرحبا');

Every character comes from the first font that has it. The fallback font is chosen per grapheme cluster, so emoji sequences such as skin tones, flags, families and keycaps stay together.

Color glyphs keep their colors in all of these formats:

  • COLR v0 and v1, including gradients and composite modes (Twemoji, Noto-COLRv1)
  • CBDT bitmaps (Noto Color Emoji)
  • sbix bitmaps (Apple's format)

Faster resizing and rotating

resize() (bilinear and area) and rotate() by angles that aren't multiples of 90° now compute in 8 bits by default.

1920×1080 photo 3.2 3.3
halve (opaque) 15 ms 12 ms
double (opaque) 80 ms 61 ms
halve (with alpha) 43 ms 24 ms
double (with alpha) 260 ms 103 ms
rotate 30° 119 ms 51 ms

Opaque pixels differ from 3.2 by at most 1. {fast: false} keeps the previous, more precise results.

maxPixels

Image.decode and Animation.decode reject images over 268 megapixels (16383², like sharp) before allocating anything. A 250-byte JPEG could previously claim 65500×65500 pixels and take gigabytes. {maxPixels: Infinity} lifts the limit.

Performance

  • Lossless WebP: encoding is about 40% faster, decoding about 15% faster. The output is identical.
  • PNG decoding uses less memory: at most 785 MB instead of 1272 MB for an 8000×8000 RGBA image.
  • Still PNGs in Animation.decode decode about 10 times as fast when they're large.

Fixes

  • NaN and infinite arguments throw a RangeError that names the argument. They used to silently draw black, transparent or nothing. For example, Image.rgbToColor(NaN, 0, 0) used to return black. Numeric strings keep working.
  • Denial of service: SVG rendering no longer backtracks on long malformed styles, url() references, lengths or colors. A 200 kB stylesheet without braces used to take a minute. <use> reference bombs, huge dash patterns and deep nesting are limited as well.
  • Infinite or huge blur and sharpen radii: blur(Infinity) used to hang and blur(1e9) took minutes.
  • Damaged images and fonts throw a clear error instead of a RangeError, and can no longer take unbounded memory or time.
  • Text outlines (strokeWidth) follow the antialiased edges of the glyphs, so they are smooth. They no longer show through translucent text.
  • Empty text: renderText used to throw for text with nothing to show. It now renders a transparent image one pixel wide.
  • Unclosed CSS rules: an SVG style rule that isn't closed at the end of the stylesheet now applies, as it does in browsers.
  • JSR docs no longer list internal members.

ImageScript is now also tested in Deno and Bun, on Linux, macOS and Windows.

Upgrading

No code changes are needed. Some results differ from 3.2:

  • Bilinear and area resizing and rotating can produce pixel values off by 1, or by about 2 for translucent pixels. Pass {fast: false} for the previous results.
  • NaN arguments throw instead of drawing nothing.
  • Images over 268 megapixels need {maxPixels: Infinity} to decode.

v3.2.1: faster resizing, blur and GIF decoding

Choose a tag to compare

@matmen matmen released this 16 Oct 09:53
Immutable release. Only release title and notes can be modified.

This release makes resizing, blurring and GIF decoding faster, and fixes a bug when enlarging with RESIZE_AREA. Nothing changes in the API.

Performance

1920×1080 photo 3.2.0 3.2.1
resize() to half (bilinear) 49 ms 15 ms
resize() to half (area) 33 ms 9 ms
resize() to double (bilinear) 392 ms 89 ms
blur(5) 205 ms 86 ms
decoding a 101-frame GIF 134 ms 83 ms

Resizing (#68)

  • The arithmetic is integer and the vertical pass runs first.
  • For opaque images, two color channels share one multiplication.
  • Bicubic resizing and images with alpha get 20–40% faster.

Blur (#69)

The three box blurs work directly on the pixels as 32-bit words, two channels per sum. Transparent images are blurred premultiplied in 8 bits, as browsers do.

Smaller speedups

  • GIF decoding (#70): frames are composited from their color indices instead of an RGBA copy of each, and they're no longer copied a second time. The output is bit-identical.
  • clone(): about 50% faster.

Fixes

  • Enlarging with RESIZE_AREA left transparent rows and columns where a pixel's center fell exactly on the edge of the filter (#68).

Upgrading

No code changes are needed. If you compare resized or blurred images against stored reference images pixel by pixel, expect small differences:

  • most values differ by at most 1 per channel (blur: 2 for radii above 10)
  • larger differences are limited to the color of pixels that are almost fully transparent, which doesn't show once they're composited

v3.2.0: smaller animations, options objects, faster codecs

Choose a tag to compare

@matmen matmen released this 16 Oct 08:01
Immutable release. Only release title and notes can be modified.

This release makes animations a lot smaller, makes options easier to pass and speeds up the most common codecs. There are no breaking changes.

New

Smaller animations (#59)

encodeGIF(), encodeAPNG() and encodeWebP() now store each frame as only the area that changed since the frame before, and merge identical frames. Within that area, unchanged pixels become transparent where that compresses better.

Re-encoding 762 animations took this share of the previous size in total:

GIF APNG WebP
51% 63% 54%
  • The less moves in an animation, the smaller it gets. Animations where everything moves stay about the same size.
  • Large animations encode faster, small ones up to about twice as slow.
  • The files show the same animation in browsers, Pillow and libwebp. They don't decode to the same frames anymore, though. {optimize: false} stores the frames as they are.

Options objects (#58)

Every method with optional arguments now also takes them as an object, so you can leave options out without counting positions:

image.resize(100, 100, {mode: Image.RESIZE_BICUBIC});
image.composite(logo, {x: 10, y: 10, opacity: 0.5});
Image.renderText(font, 32, 'Hi', {color: 0xff0000ff, strokeWidth: 2});
animation.encodeGIF({quality: 80});

The positional arguments keep working until 4.0.

Metadata in every format (#56)

encodeJPEG(), encodeWebP(), encodeTGA() and the animation encoders take the same metadata as encode(), as the metadata option:

image.encodeJPEG({quality: 80, metadata: {author: 'me'}});

Each format stores the fields it has a place for: Exif and a comment for JPEG, Exif for WebP, comments for GIF, the extension area for TGA, text chunks for PNG and APNG.

All files now name ImageScript as the software and include the creation time, as PNGs already did. {software: ''} leaves the software out.

trim() (#67)

Removes borders of one color, for example the empty space around rendered text or a solid background around a logo.

  • Without a color, the top left pixel's color is used.
  • tolerance also trims near colors, such as JPEG artifacts.
  • Frames keep their position in the animation.

Animation.frames (#60)

The frames of an animation as an array. Changing the array (push(), splice(), …) changes the animation, and you can replace it as a whole.

Performance (#62–#66)

Every change gives bit-identical results, checked against 3.1 on about 1600 images and the resvg test suite.

Faster by about
GIF decoding 25%
SVG rendering 15–20%
PNG decoding 25%
PNG encoding 10%
Lossless WebP encoding 15%
Resizing 10%

The package is also about 10 kB smaller, because the type declarations of internal modules are no longer included.

Benchmarks (#61)

bench/ compares ImageScript with Jimp, image-js, Photon, sharp, @napi-rs/canvas and ImageScript 1.x. It covers:

  • decoding and encoding
  • image operations
  • text and SVG rendering
  • animations
  • file sizes and install sizes

The results are refreshed automatically after every release.

Deprecated

  • Animations as arrays: animation[0], animation.length, animation.map() and the other array methods on an animation still work, but in 4.0 Animation won't extend Array anymore. Use animation.frames.
  • PNGMetadata is now called Metadata. The old name stays an alias until 4.0.

Fixes

  • TGA encoding: run length encoded packets end with the row, as TGA 2.0 requires. Some readers rejected the files before, Pillow among them (#57).
  • PNG text metadata: text that isn't Latin-1 (such as Ⓒ or emoji) is written as UTF-8 into iTXt chunks. Readers showed it garbled before (#56).
  • creationTime: an invalid value throws a RangeError instead of writing "Invalid Date" (#56).

Upgrading

No code changes are needed. If you compare the frames of re-encoded animations with the originals, pass {optimize: false} to the encoder, because optimized files decode to different (smaller) frames.

v3.1.0: WebP, APNG, TGA and TrueType hinting

Choose a tag to compare

@matmen matmen released this 15 Oct 08:52
Immutable release. Only release title and notes can be modified.

This release adds three image formats, crisper small text and a format-independent animation API. Everything is still plain TypeScript without dependencies.

New

WebP (#19, #20)

  • Decoding of lossy, lossless, transparent and animated WebPs. The output is bit-exact with libwebp: every reference image of libwebp's official test suite matches.
    • Still WebPs: Image.decode()
    • Animated WebPs: Animation.decode()
  • Lossless encoding: Image.encodeWebP() and Animation.encodeWebP(). The files are typically 20–30% smaller than PNG.
  • Works in Deno too (jsr:@matmen/imagescript).

Animations and APNG (#6)

  • Animation, the new base class of GIF, works with every animated format:
    • Animation.decode() reads GIF, APNG and WebP; still images become a single frame.
    • encodeGIF(), encodeAPNG() and encodeWebP() write them.
  • APNG decoding and encoding with full alpha. Broken animations fall back to the default image, like browsers do. Image.decode() still returns an APNG's default image.

TGA (#21)

Decoding of color mapped, truecolor and greyscale TGAs, uncompressed or run length encoded, in all four orientations. Image.decode() recognizes them automatically. encodeTGA() writes them.

TrueType hinting (#31)

renderText() now runs the hinting instructions of TrueType fonts, so small text comes out crisp: stems and edges are aligned with the pixel grid, like on Windows. The results match FreeType point for point.

  • Glyphs of hinted fonts are placed on whole pixels.
  • Fonts without hinting instructions (and OpenType/CFF fonts) render exactly as before.

colorToAlpha() (#39)

Makes a color transparent, for example to remove a solid background, like GIMP's "Color to Alpha". Antialiased edges become semi-transparent instead of keeping a fringe of the old background.

  • Without a color, the top left pixel's color is used.
  • Thresholds control how close a color has to be to become fully transparent.

Deprecated

  • GIF: use Animation, which works the same and also handles APNG and WebP:

    Before Now
    new GIF(frames).encode() new Animation(frames).encodeGIF()
    GIF.decode() Animation.decode()

    GIF keeps working in 3.x and will be removed in 4.0.

Fixes

  • Text rendering: glyphs touching the left edge of their bitmap could come out inverted.

Upgrading

No code changes are needed. Text rendered with hinted TrueType fonts looks slightly different (crisper, on whole pixels). To keep the previous rendering, pass {hinting: false}:

Image.renderText(font, size, text, color, layout, {hinting: false})

v3.0.0

Choose a tag to compare

@matmen matmen released this 14 Oct 19:58
Immutable release. Only release title and notes can be modified.

3.0.0

ImageScript is now written in TypeScript and has no WebAssembly or native binaries anymore. Every codec and renderer
was rewritten, the npm package shrank from 3 MB to about 120 kB, and it installs and runs on any platform Node.js
supports (including Alpine/musl and bundled serverless functions).

On JSR, @matmen/imagescript is now the same package as on npm (it used to be the Deno-only 1.x with WebAssembly),
published from the TypeScript sources with every release: deno add jsr:@matmen/imagescript.

Breaking changes

  • Node.js 22.13 or newer is required. The package is ES modules only; require('imagescript') keeps working
    through Node's require(esm).
  • All coordinates are 0-based. getPixelAt, setPixelAt, getRGBAAt, iteration, fill callbacks, drawBox
    and drawCircle used 1-based coordinates while crop and composite used 0-based offsets. Code that addressed
    pixels starting at 1 now has to subtract 1; out of range coordinates throw.
  • WebP encoding and encodeWEBP() were removed, use encode() (PNG) or encodeJPEG().
  • invertSaturation() and invertHue() were removed. invertHue() mirrored the hue (h -> 1 - h); for the
    complementary color use hueShift(180).
  • The experimental imagescript/v2 API was removed.
  • TextLayout alignment options are named correctly: horizontalAlign takes left/center/right,
    verticalAlign takes top/middle/bottom. Values passed under the old (swapped) names are still accepted.
    Alignment and wrapStyle: 'char' actually work now, which changes the output of code that set them.
  • GIF.encode(quality) scales the palette size: 1 = 8 colors, 50 = 55, 90 and above = 256.
  • GIF.decode returns the GIF's real loop count (it always returned -1); GIFs without a loop extension play once (0).
  • Resizing defaults to bilinear filtering (Image.RESIZE_BILINEAR) instead of nearest neighbor, which aliased
    badly when shrinking photos. Pass Image.RESIZE_NEAREST_NEIGHBOR for pixel art; it now samples the pixel centers,
    which shifts its output by up to a pixel. RESIZE_AUTO rounds the computed side instead of flooring it.
  • drawCircle(), cropCircle() and roundCorners() are antialiased. drawCircle(x, y, r) is centered on the
    center of pixel (x, y) and has a radius of exactly r. cropCircle(max, feathering): feathering is now the share
    of the radius (0..1) over which the edge fades out; it used to fade the whole circle.
  • dominantColor() groups the colors into clusters (median cut and k-means) and returns the average color of the
    largest one, instead of the most common bucket of a fine HSL histogram (which returned shifted colors, e.g. #fb0000
    for pure red, and let small flat areas beat large varied ones). bwThreshold (still 0-64) now ignores colors whose
    channels are all below / above the threshold, so dark saturated colors like navy are no longer discarded as black.
  • Text is laid out with subpixel precision and kerning (GPOS and kern tables). Glyph advances used to be rounded
    up to whole pixels, so text gets narrower and more evenly spaced.
  • encode() writes the smallest lossless PNG color type: a palette for images with up to 256 colors, otherwise
    greyscale or RGB, with an alpha channel only if needed. Rows of non-palette images are paeth filtered, which makes
    photos about 25% and gradients up to 95% smaller. Compression uses the runtime's native zlib when available
    (node:zlib), which is several times faster than the JS fallback.
  • JPEGs, TIFFs and PNGs are rotated/mirrored as their orientation tag says, like browsers display them. Photos
    taken with a rotated camera used to come out sideways. PNGs take it from the eXIf chunk, or without one from
    ImageMagick's Exif text or the XMP packet (which browsers ignore). Image.decode(data, {applyOrientation: false}) keeps the
    stored pixels.
  • rgbaToColor() / rgbToColor() round and clamp the components to 0..255. Out of range values used to wrap
    around (256 became 0) and fractions were truncated.
  • Frame.disposalMode returns the mode's name ('keep', ...), matching the Frame.DISPOSAL_* constants and what
    it is set to; it used to return the numeric id.
  • invertValue() inverts the HSV value (the brightest channel v becomes 255 - v, black becomes white). It used
    to invert HSL lightness, which invertLightness() does now.
  • rotate() is documented as counterclockwise, which it always was.
  • averageColor() weights pixels by their opacity.

Fixes

  • rotate(90) / rotate(270) scrambled non-square images (#29); free rotation lost the image edges
  • composite() darkened semi-transparent pixels drawn onto transparent ones
  • crop() read pixels from the next row when the region exceeded the image
  • drawBox() threw when the box was outside the image
  • cropCircle(max) ignored max
  • roundCorners() was asymmetric and did nothing to the right/bottom corners for fractional radii
  • Frame.resize() returned a frame without its duration, offsets and disposal mode; Frame.clone() returned an Image
  • GIF.slice() without an end, GIF.map() and GIF.filter() threw
  • GIF.resize() stretched partial frames to the full size
  • encode({...metadata}) ignored the metadata
  • fisheye() shifted the image by a pixel
  • HSL conversions truncated instead of rounding, so lightness(1), saturation(1) or hueShift(360) changed the
    image and repeated adjustments drifted darker; negative hues (e.g. hueShift(-300)) produced wrong colors
  • cover() could return an image a pixel smaller than requested, contain() one a pixel smaller than the box
  • Big endian TIFFs weren't recognized, and most TIFFs failed to decode
  • Text alignment was ignored (#37)
  • Assigning image.bitmap left getPixelAt() and other methods reading the old pixels
  • GIF frame durations were truncated to centiseconds (16 ms became 10 ms), they are rounded now
  • invert(), dominantColor(), rotate(), hueShift(), lightness(), saturation() and the other HSL based
    functions are several times faster; PNG decoding uses native zlib when available

New

  • blur(radius) (gaussian, radius = standard deviation like CSS' blur()) and sharpen(amount, radius) (unsharp mask)
  • brightness(factor), contrast(factor), gamma(gamma), greyscale() (ITU-R 601 luma, like Pillow) and sepia()
  • composite(source, x, y, {opacity, mode}) with the blend modes of CSS' mix-blend-mode (multiply, screen,
    overlay, darken, lighten, color-dodge, color-burn, hard-light, soft-light, difference, exclusion)
    and add
  • Outlined text: renderText(font, size, text, color, layout, {strokeWidth, strokeColor})
  • invertLightness(), which does what invertValue() used to do (invert HSL lightness)
  • encodePNG(), an alias of encode()
  • Options objects as an alternative to positional arguments: cropCircle({max, feathering}),
    dominantColor({ignoreBlack, ignoreWhite, threshold}), rotate(angle, {resize})
  • Frame.DISPOSAL_ANY; ImageType.isPNG() and the other checks accept any binary data, not only a DataView
  • Parameter names describe what they are: red/green/blue(factor), renderText(font, size, ...), fisheye(strength)
  • Resize modes Image.RESIZE_BILINEAR (default), Image.RESIZE_BICUBIC and Image.RESIZE_AREA, all usable with
    resize, scale, contain, fit, cover and GIF.resize

Codecs

  • PNG: all color types and bit depths, interlacing, tRNS transparency, checksum validation; inflating uses the
    runtime's native zlib when available
  • JPEG: baseline and progressive decoding (bit-exact with libjpeg-turbo), encoding with optimized Huffman tables
  • GIF: encoding with a median cut quantizer and dithering, tolerant decoding
  • TIFF: strips and tiles, LZW/deflate/PackBits/JPEG compression, 1-32 bit and float samples, CMYK and YCbCr, BigTIFF
  • Fonts: TrueType and OpenType (CFF) outlines; much faster for large fonts
  • SVG: shapes, paths, transforms, use/symbol, CSS, gradients, strokes, clip paths and masks. Text, images, filters
    and patterns are not rendered.

Full Changelog: v1.3.1...v3.0.0