Skip to content

Releases: imagescript/ImageScript

3.14.0

Choose a tag to compare

@matmen matmen released this 04 Nov 20:40
Immutable release. Only release title and notes can be modified.

3.14.0

New

  • Lossless encodeJXL() compresses like libjxl's cjxl -e 3 (effort 3) by default: about 15% smaller files for
    photos (a 1920x1080 photo: 1.17 MB instead of 1.39 MB) and 13% for graphics, within 1-2% of cjxl's sizes. It
    uses a palette of up to 1024 colors where it fits (else palettes of the channels that use few of their values),
    YCoCg and the weighted predictor in contexts of its error, and codes the groups in worker threads
    (JXLEncodeOptions.threads, the same files): the photo takes 0.24 s with 8 threads, 0.65 s with one. effort: 0
    keeps the fast mode, byte for byte what cjxl -e 1 writes (0.11 s for the photo); effort applies to lossless
    compression now too.

Performance

  • Lossy encodeJXL() is faster: a 1920x1080 photo at quality 90 takes 1.6 s instead of 2.3 s at effort 1 and 2.4 s
    instead of 4.8 s at effort 2 on one thread; with 8 threads 0.72 s instead of 0.81 s and 0.95 s instead of 1.46 s
    (3840x2160: 1.6 s instead of 2.0 s and 2.5 s instead of 4.1 s). The 4x8 and AFV transforms are computed directly
    instead of by a 64x64 matrix, the varblock search rounds without Math.round() (slow in V8), patch search skips
    large components after a quick labeling, and the search is split into smaller pieces for the threads. The files stay
    the same, except for rare coefficients the new transforms round differently (2 of 108 test encodes, both the same
    size).

Fixes

  • Invalid options throw a RangeError naming them before any work, instead of failing inside an encoder or being
    ignored:
    • encodeAVIF(): effort other than 0, 1 or 2 failed with a TypeError deep in the encoder, quality and
      alphaQuality outside 0-100 were accepted, and an unknown subsampling silently meant 4:4:4 (now a
      TypeError).
    • encodeWebP(), Animation.encodeWebP() and Animation.webpEncoder() check effort for lossless images too
      (which don't use it).
    • NaN is refused as GIF quality and APNG compression (out of range values are still clamped, like PNG's), as
      dominantColor()'s threshold and as renderSVG()'s size (which threw about the image's width); negative font
      sizes in renderText() (which rendered an empty image); maxPixels that isn't positive in Image.decode() and
      Animation.decode() (which reported every image as too large); fractional loop counts in new Animation().
  • Animation.decode() reports invalid threads and maxPixels as such, not as damaged image data.
  • Image.decode() refuses GIFs with a hint at Animation.decode() instead of "Unsupported image type".
  • SVG feDisplacementMap applies its scale once, like the specification and browsers (it multiplied by it twice, like
    resvg, so at typical scales the whole input was displaced out of the filter region and disappeared), and reads the
    map's colors unpremultiplied.

3.13.2

Choose a tag to compare

@matmen matmen released this 04 Nov 13:27
Immutable release. Only release title and notes can be modified.

3.13.2

Fixes

  • Lossless JPEG XLs whose color space their codestream names, without an ICC profile, are converted to sRGB like
    ones with a profile (#83). cjxl stores Display P3 images this way, and they came out washed out because the P3
    values were read as sRGB: up to 41 levels off LittleCMS's conversion, now within 1. Applies to Image.decode and
    Animation.decode; turn it off with applyColorProfile: false. The P3 and BT.2100 primaries, custom primaries and
    white points, and linear, DCI and gamma transfer functions are all converted. Lossy (XYB) JPEG XLs, ones with an
    ICC profile and HDR ones (PQ, HLG) decode as before.

3.13.1

Choose a tag to compare

@matmen matmen released this 31 Oct 19:38
Immutable release. Only release title and notes can be modified.
f69d06b

Changes

  • Shortened default creation software tag to ImageScript vX.Y.Z to prevent truncated software tags in TGA files
  • Moved GitHub repository to imagescript/ImageScript

3.13.0

Choose a tag to compare

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

This release brings WOFF fonts everywhere, smaller PNGs by default, much faster lossy WebP decoding and more accurate hinting. There are no breaking API changes, but PNGs come out smaller and a little slower by default (see below).

const inter = await fs.readFile('node_modules/@fontsource/inter/files/inter-latin-400-normal.woff');
await Image.renderText(inter, 32, 'Hello');   // WOFF fonts work in renderText, TextLayout and renderSVG
await image.encode();                         // PNG: compression level 4 by default (was 1)

WOFF fonts

renderText(), TextLayout and SVG rendering's fonts option take WOFF fonts now, not only TrueType and OpenType ones. Font packages like Fontsource ship only WOFF and WOFF2, so their fonts can be used directly. WOFF2 fonts are refused with a clear error for now: they need Brotli, which Chrome doesn't offer to JavaScript yet.

Smaller PNGs by default

PNG and APNG encoding default to compression level 4 instead of 1: files are 7-22% smaller (graphics 11%, photos 10%, screenshots 22%) for about 30% more encoding time. Pass compression: 1 for the previous speed. This applies in Node.js and Bun; elsewhere levels 1-6 already compressed like level 6.

Faster lossy WebP decoding

Lossy WebP decoding is about 45% faster (1920x1080 at quality 75: 184 → 100 ms), with the same pixels. The decoder's main loop kept getting deoptimized by V8: reading a DC prediction mode returned -0, which V8 treats as a floating point number.

More accurate hinting

TrueType hinting scales the fonts' control values exactly like FreeType. Before, some points could land 1/64 pixel off, sometimes across a pixel boundary: of one test font's glyphs at 11-128 px, 60 of 72 matched FreeType, now all of them do.

Tests

Many new tests cover the decoders' rarer paths, each against its reference implementation (libjpeg-turbo, libwebp, libvpx, libavif, libjxl, FreeType, HarfBuzz, resvg): unusual JPEG subsamplings and YCCK, interlaced GIFs and PNGs, every WebP loop filter variant, CID-keyed and flex CFF fonts, more complex script shaping, AV1 timing info, and the code paths for big endian machines.

See the changelog for the details.

3.12.2

Choose a tag to compare

@matmen matmen released this 31 Oct 00:43
Immutable release. Only release title and notes can be modified.

This release makes JPEG encoding about 30% faster and byte for byte identical to libjpeg-turbo, and gives ImageScript a logo. There are no API changes.

await image.encodeJPEG(90);   // the same bytes as cjpeg -quality 90 -baseline -optimize (but for the Exif segment)

JPEG encoding like libjpeg-turbo

encodeJPEG() now uses libjpeg-turbo's integer pipeline instead of floating point: its color conversion, chroma downsampling, DCT and quantization, including how it pads partial blocks at the image's edges. The files are exactly what cjpeg -quality <q> -baseline -optimize writes (with -sample 1x1 above quality 90), apart from our Exif segment. Over 490 test encodes at every quality, all were identical, and the tests check this against cjpeg's output.

  • About 30% faster: the integer pipeline, chroma converted and subsampled two rows at a time (16 MB less memory for a 1920x1080 image) and leaner entropy coding.
  • Slightly different files: photos come out 0.1-1% larger at the same quality. Rendered images (screenshots, graphics) at quality 90 and above can be up to 9% larger, mostly because libjpeg rounds the color samples to 8 bits. These are the same files sharp, Pillow and other libjpeg-based tools produce.

JPEG decoding now upsamples chroma a row at a time while converting to RGB, about 6 MB less memory for a 1920x1080 image, with the same pixels and speed.

Logo

ImageScript has a logo: it heads the README on GitHub, npm and JSR, and the documentation at imagescript.dev now has a favicon and shows a preview with the logo when you share a link.

See the changelog for the details.

3.12.1

Choose a tag to compare

@matmen matmen released this 30 Oct 23:40
Immutable release. Only release title and notes can be modified.

This release makes lossless WebP files smaller, especially for graphics, screenshots and images with transparency. There are no API changes.

await image.encodeWebP();   // lossless: the benchmark graphic now takes 277 kB instead of 557 kB

Lossless WebP

  • Transforms chosen by entropy analysis: like libwebp, the encoder now estimates whether the predictor and subtracting green pay off instead of always predicting. Graphics with flat areas and repeated parts compress much better without the predictor: the 1434x1434 benchmark graphic takes 277 kB instead of 557 kB (cwebp: 278 kB), screenshots and images with transparency 15-30% less. Photos stay the same.
  • Color transform: after the predictor, red and blue are predicted from green per 32x32 block, like libwebp does. Emoji and rendered graphics get 5-6% smaller, about 0.6% on average, at about 9% more encoding time.

Over 39 test images, our lossless WebP bitstreams went from 1.11 to 1.04 times the size of cwebp's (-m 4 -q 75, sharp's default). As before, all of it is plain TypeScript: no WebAssembly and no native code.

Documentation

The documentation moved to imagescript.dev. Links to imagescript.matmen.dev redirect there.

See the changelog for the details.

3.12.0

Choose a tag to compare

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

This release adds variable fonts to text and SVG rendering, and converts AVIFs with wide gamut color primaries (Display P3, BT.2020) to sRGB when decoding. Like every codec and renderer in ImageScript, it's plain TypeScript: no WebAssembly and no native code. There are no breaking API changes, but decoded pixels of AVIFs with other primaries than sRGB's change (see below).

await Image.renderText(robotoFlex, 32, 'Hello', { variations: { weight: 700, width: 87.5 } });
await Image.renderText(robotoFlex, 32, 'Hello', { variations: { weight: 500, GRAD: 50 } }); // custom axes by tag
await Image.renderSVG(svg, { fonts: [interVariable] });   // font-weight: 650 → Inter at wght 650
await Image.decode(p3Avif);                               // now converted from Display P3 to sRGB

Variable fonts

renderText() renders the instance TextStyle.variations asks for:

  • Axes by name: weight (1-1000), width (percent of normal), slant (degrees), italic (true/false), opticalSize (pixels); other axes by their tag, like Roboto Flex's GRAD
  • Optical size: the font size unless given, like browsers' font-optical-sizing: auto, so small text uses the font's sturdier small-size design
  • Values outside an axis's range are clamped, axes a font doesn't have are ignored, and misspelled names throw a RangeError

renderSVG() uses variable fonts like browsers do: font-weight, font-stretch and font-style set the weight, width, italic and slant axes, font-optical-sizing the optical size, and font-variation-settings overrides them. A variable font covers its whole range of weights and widths when fonts are matched, like several faces of a family.

What varies: TrueType outlines (with composite glyphs), advances, line metrics, kerning and mark positions, the glyphs fonts substitute at some axis values, and hinting. Outlines and advances are the same as fontTools' and HarfBuzz's, shaping matches HarfBuzz, and all 14 of resvg's font-variation-settings tests pass. Variable fonts with CFF2 (PostScript) outlines aren't supported yet.

AVIF color primaries

AVIFs without an ICC profile whose color information gives other primaries than sRGB's (Display P3, BT.2020, DCI-P3, ...) or a gamma 2.2, 2.8 or linear transfer are converted to sRGB when decoding, like browsers show them, relative colorimetric and within 1 of LittleCMS. applyColorProfile: false keeps the stored values. Most AVIFs (sRGB/BT.709 or unspecified primaries) stay the same.

See the changelog for the details.

3.11.0

Choose a tag to compare

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

This release converts JPEG XLs with an embedded ICC color profile to sRGB when decoding, like PNG, JPEG, WebP, AVIF and TIFF already were, and fixes the JSR package: 3.10.0 only made it to npm. There are no breaking API changes, but decoded pixels of JPEG XLs with a non-sRGB profile change (see below).

await Image.decode(losslessP3Jxl);                              // now converted from Display P3 to sRGB
await Image.decode(losslessP3Jxl, { applyColorProfile: false }); // the stored values, as before

JPEG XL color profiles

JPEG XLs whose pixels are stored in their embedded profile's color space (lossless ones, and JPEGs recompressed with cjxl) are converted to sRGB, in Image.decode() and Animation.decode(), the way browsers display them.

  • The profile is decoded from JPEG XL's compressed ICC stream (ported from jxl-oxide), byte for byte the profile libjxl reads
  • It's applied like the other formats' profiles: matrix/TRC profiles relative colorimetric, within 1 of LittleCMS; profiles with only lookup tables, CMYK and sRGB profiles leave the pixels as they are
  • Lossy (XYB) JPEG XLs already decoded to sRGB; their profile only describes what the image was before encoding, so they stay the same
  • applyColorProfile: false keeps the stored values

Fixes

  • 3.10.0 is on npm but JSR refused it (a global type declaration for import.meta.url), so JSR's latest was still 3.9.0. 3.11.0 is on both, with everything from 3.10.0: worker threads for AVIF, JPEG XL and lossless WebP, and the faster SVG rasterizer (release notes). The release check now catches this before anything is published.

See the changelog for the details.

3.10.0

Choose a tag to compare

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

This release is about speed: AVIF, JPEG XL and lossless WebP encode and decode in worker threads, with byte-for-byte the same files and pixels as on one thread, and SVG rendering is faster too. Like every codec in ImageScript, it's still plain TypeScript: no WebAssembly and no native code. There are no breaking changes.

await image.encodeAVIF();            // in threads: 1.7 s instead of 4.9 s for a 1920x1080 photo
await image.encodeJXL({ quality: 90 }); // 0.86 s instead of 2.2 s
await Image.decode(bigJxl);           // lossy 4K: 395 ms instead of 640 ms
await image.encodeWebP({ threads: 1 }); // threads can be limited or turned off everywhere

Worker threads

Encoders and decoders split their work into worker threads where it pays off: by default as many as the processors available, at most 8. The new threads option (AVIFEncodeOptions, JXLEncodeOptions, WebPEncodeOptions, DecodeOptions, AnimationDecodeOptions) limits them, 1 turns them off. Every result is the same as without threads.

8 threads, Ryzen 7 5800X before now
AVIF encode, 1920x1080, effort 1 4.9 s 1.7 s
AVIF encode, 1920x1080, effort 2 14.2 s 3.4 s
JPEG XL encode lossy, 3840x2160, effort 1 8.5 s 2.0 s
JPEG XL encode lossy, 3840x2160, effort 2 19.2 s 4.8 s
JPEG XL decode lossy, 3840x2160 640 ms 395 ms
JPEG XL decode lossless, 3840x2160 671 ms 304 ms
AVIF decode, 3840x2160 4:2:0 466 ms 355 ms
AVIF decode, 6400x2880 grid 2.68 s 1.12 s
WebP encode lossless, 3862x2172 2.34 s 1.62 s
  • What runs in parallel: AVIF superblock rows in a wavefront (like libaom's row-based multithreading, so no loss in compression) and the filter trials; JPEG XL's XYB conversion, quantization field, varblock search, coefficients and tokens when encoding, its groups, Gaborish, EPF and color conversion when decoding; AVIF's conversion to RGB and grid tiles; WebP's predictor and prefix code search
  • When: only for images large enough to gain from it. The thresholds were measured with 1 to 8 threads on 0.25 to 8 megapixels of photos, screenshots, images with few colors and transparency; below them (and for small lossless JPEG XLs) everything runs on the calling thread as before
  • Where: Node.js (worker_threads), Deno, Bun and browsers (Web Workers; bundlers like Vite pick up the worker's module). Browser pages without cross-origin isolation, edge runtimes and other runtimes without shared memory between workers run on one thread
  • Lossy JPEG XL decoding no longer keeps the coefficients of the whole frame (about 100 MB less at 4K)

Faster on one thread too

  • SVG rendering: the rasterizer is 23-36% faster (500 text labels: 15% faster in all), with the same pixels on all of resvg's test files
  • JPEG XL: lossless images decode straight to RGBA bytes (6% faster), and the patch search at effort 2 is about 30% faster
  • AVIF: 4:2:0 and 4:2:2 images convert to RGB about 7% faster

Tests

Edge runtimes are tested on every commit: Cloudflare's workerd (with and without nodejs_compat) and Vercel's Edge Runtime run every codec and the renderers bundled like deployments, and Chromium runs them bundled by Vite, with threads on a cross-origin isolated page and without on another.

See the changelog for the details.

3.9.0

Choose a tag to compare

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

This release renders SVG text, converts images with embedded ICC color profiles to sRGB when decoding, hints glyphs exactly like FreeType, and adds patches to lossy JPEG XL and alpha quality to lossy WebP. SVG rendering also gains blend modes, transform-origin, paint-order and context paints. Like every codec in ImageScript, it's plain TypeScript: no WebAssembly and no native code. There are no breaking API changes, but decoded pixels of images with a non-sRGB ICC profile change (see below).

await Image.renderSVG(svg, { fonts: [await fs.readFile('Inter.ttf')] }); // <text>, <tspan>, <textPath>, ...
await Image.decode(p3Photo); // now converted from Display P3 to sRGB
await Image.decode(p3Photo, { applyColorProfile: false }); // the stored values, as before
await image.encodeJXL({ quality: 90, effort: 2 }); // patches for screenshots and text
await image.encodeWebP({ quality: 80, alphaQuality: 50 });

New

SVG text

Image.renderSVG() renders <text>, <tspan>, <tref> and <textPath> with the fonts passed as SVGOptions.fonts and the ones the SVG embeds with @font-face (TrueType, OpenType and WOFF data: URIs).

  • Font matching like browsers match installed fonts: font-family, then font-stretch, font-style and font-weight by CSS Fonts' algorithm, generic families by the fonts' panose and pitch flags, and missing characters from the other fonts
  • Layout ported from resvg (usvg): chunks shaped as a whole (kerning and ligatures across spans), bidirectional text, x/y/dx/dy/rotate lists, text-anchor, letter-spacing, word-spacing, textLength, baseline-shift, dominant-baseline, text-decoration, text along paths, vertical text, font-variant: small-caps, the font shorthand and xml:space
  • Painting like shapes (gradients, patterns, strokes, paint-order), in clip paths and bounding boxes, with color glyphs (COLR v0/v1, CBDT, sbix, SVG)
  • 359 of resvg's 431 text tests pass, plus 41 of its other tests that contain text. Variable fonts aren't supported yet

ICC color profiles

Decoding converts images with an embedded ICC profile to sRGB, the way browsers display them: PNG (iCCP), JPEG (APP2), WebP (ICCP, animations too), AVIF (colr) and TIFF. Display P3 photos from phones and Adobe RGB or ProPhoto images no longer look washed out.

  • Matrix/TRC profiles (RGB and gray) are applied relative colorimetric. The results are within 1 of LittleCMS's
  • Profiles with only lookup tables, CMYK profiles and sRGB profiles leave the pixels as they are
  • applyColorProfile: false (in Image.decode() and Animation.decode() options) keeps the stored values
  • A 1920x1080 image takes about 35 ms to convert

Patches in lossy JPEG XL

encodeJXL({ quality, effort: 2 }) stores repeated text-like features (glyphs on the flat backgrounds of screenshots) once in a reference frame and adds them back where they occur, like libjxl's patches (cjxl -e 7), ported from it. A page of text takes 62% fewer bytes, screenshots 1-2% fewer. Other images come out the same.

Alpha quality in lossy WebP

encodeWebP({ quality, alphaQuality }) (0-100, default 100; also for animations and Animation.webpEncoder()) keeps fewer alpha levels below 100, with the same alpha values as cwebp's -alpha_q. For example, potato.png's alpha plane takes 1.3 kB at 50 instead of 1.9 kB, and a gradient takes 4.3 kB instead of 16.7 kB.

SVG rendering

  • Blending: mix-blend-mode (all 16 modes and plus-lighter) and isolation: isolate. All 23 of resvg's tests for them pass
  • transform-origin on elements, clip paths, gradients and patterns (21 of 23 tests)
  • paint-order (fill, stroke, markers; 12 of 14 tests)
  • context-fill / context-stroke in markers and <use> (13 of 16 tests)
  • shape-rendering: crispEdges/optimizeSpeed draw without antialiasing, and the root's background-color is used
  • font-size resolves em, ex, percentages and keywords against the parent's size everywhere; font-weight accepts bolder/lighter

Text rendering

Hinting (renderText with hinting) now places every point exactly where FreeType's TrueType interpreter (v35) puts it. This was checked on 18 fonts at 8-48 px and on all 6190 glyphs of DejaVu Sans at 4 sizes. Before, up to 30% of the glyphs of some fonts were off by fractions of a pixel, and a few were off by up to 4 pixels.

Fixes

  • SVG: zero-length dashes at the start of a path get caps (square ones turned along the path)
  • SVG: shapes without a bounding box painted with an objectBoundingBox gradient or pattern use its fallback color
  • SVG: display: none elements no longer count towards group bounding boxes
  • SVG: overflow="inherit" on markers and nested viewports takes the parent's value
  • SVG: path data ends at the first character that isn't part of it, instead of that character being skipped
  • SVG: CR LF and CR line ends read as line feeds, like in XML parsers