- Lossless
encodeJXL()compresses like libjxl'scjxl -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: 0keeps the fast mode, byte for byte whatcjxl -e 1writes (0.11 s for the photo);effortapplies to lossless compression now too.
- 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 withoutMath.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).
- Invalid options throw a
RangeErrornaming them before any work, instead of failing inside an encoder or being ignored:encodeAVIF():effortother than 0, 1 or 2 failed with aTypeErrordeep in the encoder,qualityandalphaQualityoutside 0-100 were accepted, and an unknownsubsamplingsilently meant 4:4:4 (now aTypeError).encodeWebP(),Animation.encodeWebP()andAnimation.webpEncoder()checkeffortfor lossless images too (which don't use it).NaNis refused as GIFqualityand APNGcompression(out of range values are still clamped, like PNG's), asdominantColor()'sthresholdand asrenderSVG()'s size (which threw about the image's width); negative font sizes inrenderText()(which rendered an empty image);maxPixelsthat isn't positive inImage.decode()andAnimation.decode()(which reported every image as too large); fractional loop counts innew Animation().
Animation.decode()reports invalidthreadsandmaxPixelsas such, not as damaged image data.Image.decode()refuses GIFs with a hint atAnimation.decode()instead of "Unsupported image type".- SVG
feDisplacementMapapplies itsscaleonce, 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.
- 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.decodeandAnimation.decode; turn it off withapplyColorProfile: 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.
- Shortened default creation software tag to
ImageScript vX.Y.Zto prevent truncated software tags in TGA files - Moved GitHub repository to imagescript/ImageScript
- WOFF fonts work everywhere fonts do:
renderText(),TextLayout, and SVG rendering'sfontsoption (before, only fonts an SVG embeds with@font-facecould be WOFF). 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'sDecompressionStreamdoesn't offer yet.
- PNG and APNG encoding (
encode(),encodePNG(),encodeAPNG(),apngEncoder()) default to compression level 4 instead of 1: 7-22% smaller files (graphics 11%, photos 10%, screenshots 22%) for about 30% more time. zlib's lazy matching starts at level 4, so levels 2-3 save less. Passcompression: 1for the previous speed. (Node.js and Bun; elsewhere levels 1-6 compress withCompressionStream, whose level is fixed at 6.)
- Lossy WebP decoding is about 45% faster (1920x1080 at quality 75: 184 -> 100 ms of CPU time): reading a DC prediction mode returned -0, a floating point number to V8, which kept deoptimizing the decoder's main loop. Blocks with only a DC coefficient also skip the full inverse transform. Same pixels.
- TrueType hinting scales the control values like FreeType: kept in 1/64 font units (cvar's deltas rounded to them) and scaled by the scale cut to 1/64. Rounding differently moved some points by 1/64 pixel, sometimes across a pixel boundary: of Ethnocentric's glyphs at 11-128 px, 60 of 72 were FreeType's before, all of them now.
- Tests for the decoders' rarer paths, each against its reference implementation: JPEG 4:4:0, 4:1:1, YCCK and fill bytes (Pillow), interlaced GIFs (Pillow), 16-bit RGB and interlaced RGBA PNGs (PngSuite), WebP's simple loop filter, sharpness and loop filter deltas (dwebp, a libvpx keyframe), CFF flex operators, CID-keyed CFF, scaled composite glyphs, cmap formats 0, 6 and 14, phantom point and cvar variations, hinting in the twilight zone (FreeType), more shaping inputs of HarfBuzz's test suite (USE, Indic, Myanmar, ...), more of resvg's SVG tests, AV1 timing info and frame ids, YCgCo, 180° rotations and items in several extents in AVIF (avifdec), JPEG XL custom primaries and tone mapping (djxl), and the code paths for big endian machines (forced on).
encodeJPEG()encodes like libjpeg-turbo: its integer color conversion, downsampling, DCT and quantization instead of floating point ones, so the files are byte for byte whatcjpeg -quality <q> -baseline -optimizewrites (with-sample 1x1above quality 90), but for the Exif segment. Quality 1-49 also uses libjpeg's integer scaling of the quantization tables now. Files differ slightly from before: photos 0.1-1% larger at the same quality, rendered images (screenshots, graphics) at quality 90 and above up to 9%, mostly as libjpeg rounds the YCbCr samples to 8 bits.
encodeJPEG()is about 30% faster: the integer pipeline, chroma converted and subsampled two rows at a time (instead of full-size planes first, 16 MB less for a 1920x1080 image), and entropy coding in two specialized passes.- JPEG decoding upsamples chroma a row at a time while converting to RGB, and uses unscaled planes as they are, instead of first building full-size copies of all of them (about 6 MB less for a 1920x1080 image). Same output and speed.
- Lossless
encodeWebP()chooses whether to use the predictor and to subtract green by libwebp's entropy analysis, 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. Over 39 test images the bitstreams went from 1.11 to 1.05 times cwebp's (-m 4 -q 75, like sharp's default). - Lossless
encodeWebP()uses the color transform after the predictor, choosing per 32x32 block how much of green to subtract from red and of green and red from blue, like libwebp: 5-6% smaller for images like emoji and rendered graphics, about 0.6% on average, and about 9% slower. Over the same 39 images: 1.04 times cwebp's. - The documentation moved to imagescript.dev; imagescript.matmen.dev redirects there.
- Variable fonts:
renderText()renders the instanceTextStyle.variationsasks for, by axis names (weight,width,slant,italic,opticalSize) or tags (like Roboto Flex'sGRAD), clamped to the axes' ranges; the optical size is the font size unless given, like browsers'font-optical-sizing: auto.renderSVG()mapsfont-weight,font-stretchandfont-styleto the axes like browsers and resvg do, withfont-optical-sizingandfont-variation-settings, and matches a variable font's ranges of weights and stretches like several faces. TrueType outlines (gvar, with the interpolation of untouched points), advances (HVAR, else gvar's phantom points), metrics (MVAR), GPOS adjustments and anchors (variation indices), feature variations and hinting (cvar) vary; CFF2 outlines aren't supported yet. Outlines and advances are the same as fontTools' and HarfBuzz's, shaping is within HarfBuzz's rounding, and all 14 of resvg'sfont-variation-settingstests pass.
- AVIFs without an ICC profile whose nclx box (or sequence header) gives other color 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 (
applyColorProfile: falsekeeps the stored values): as a matrix/TRC profile of those primaries, relative colorimetric, within 1 of LittleCMS. Transfers of the BT.709 family are taken as sRGB's curve, so most AVIFs (BT.709 or unspecified primaries) stay the same.
- 3.10.0 is on npm but not on JSR, which refused it for declaring
import.meta.url's type globally; this version is published to both again.
- JPEG XLs with an embedded ICC profile whose pixels are in its color space (lossless ones and recompressed JPEGs, not
XYB) are converted to sRGB when decoding, like the other formats' (
applyColorProfile: falsekeeps the stored values): the profile's stream is decoded (ported from jxl-oxide; the same profiles as libjxl's), then applied like PNGs' and JPEGs'. Lossy (XYB) JPEG XLs already decode to sRGB, their profiles only say what the image was before.
encodeAVIF()encodes in worker threads (AVIFEncodeOptions.threads, default: the processors available, at most 8;1for none), writing the same files: superblock rows are searched in a wavefront (each superblock after the one above right of it), like libaom's row-based multithreading, so there is no loss in compression, and the loop filter and CDEF trials run in parallel. A 1920x1080 photo takes 1.1 s instead of 2.1 s at effort 0, 1.7 s instead of 4.9 s at effort 1 and 3.4 s instead of 14.2 s at effort 2 (8 threads, Ryzen 7 5800X). Works in Node (worker_threads), Deno, Bun and browsers (Web Workers; bundlers like Vite pick up the worker's module). Small images (under about 640x640 pixels at effort 0, 448x448 at 1, 320x320 at 2), browser pages without cross-origin isolation (no SharedArrayBuffer), runtimes without workers, and workers that fail to start encode on the calling thread as before.- Lossy
encodeJXL()encodes in worker threads (JXLEncodeOptions.threads, the same default): XYB, the quantization field, the varblock search (rows of 64x64 areas, like libjxl's), the coefficients and their tokens run in parallel, writing the same files. A 1920x1080 photo takes 0.20 s instead of 0.28 s at effort 0, 0.86 s instead of 2.2 s at effort 1 and 1.7 s instead of 4.9 s at effort 2, a 3840x2160 one 0.42 s instead of 0.89 s, 2.0 s instead of 8.5 s and 4.8 s instead of 19.2 s (8 threads). Images under about 1.5 megapixels at effort 0, 512x512 pixels at 1 and 384x384 at 2 encode without threads. - JPEG XL decoding uses worker threads (
DecodeOptions.threadsandAnimationDecodeOptions.threads, the same default), lossy images from 4 megapixels on (one thread per 2 megapixels at most), lossless ones from 1.5 megapixels and 512 KiB on: the groups (with all their passes), Gaborish, the EPF's strips and the conversion to sRGB run in parallel, with the same pixels. A 3840x2160 image decodes in 395 ms instead of 640 ms lossy and 328 ms instead of 671 ms lossless, a 5120x2880 one in 568 ms instead of 1121 ms and 487 ms instead of 1122 ms. Lossy frames no longer keep the coefficients of the whole frame (about 100 MB at 4K): each group is rendered once its passes are read. - SVG rendering rasterizes paths 23-36% faster (a 1000x1000 SVG of 500 text labels: about 15% faster in all, the test SVG at 1920 pixels wide about 13%), with the same pixels: the edges in typed arrays bucketed by their first row, the active ones kept sorted by x so each sub-scanline's crossings come nearly sorted, the working arrays reused.
encodeJXL({ quality, effort: 2 })searches patches about 30% faster (the connected components with a typed stack of pixel indices instead of an array of coordinates, in the same order): the same files.- Lossless
encodeWebP()searches the predictors and the prefix groups (the k-means of the blocks' histograms) in worker threads (WebPEncodeOptions.threads, the same default), from 1.5 megapixels on: the same files. A 2731x1536 photo takes 1.07 s instead of 1.39 s, a 3862x2172 one 1.62 s instead of 2.34 s (8 threads); the backward references stay on one thread. - The thresholds for threads were measured with 1 to 8 threads on 0.25 to 8 megapixels: lossy JPEG XL encoding at efforts 1 and 2 and AVIF encoding use 4 threads up to about 1-2 megapixels (more were slower there), JPEG XL at effort 0 starts at 1.5 megapixels, lossless JPEG XL decoding at 1.5 instead of 4 (and 512 KiB: smaller files, like ones of few colors, decode faster than threads start). Checked on screenshots, few colors and alpha too.
- AVIF decoding uses worker threads (
DecodeOptions.threads) from 4 megapixels on: the conversion to RGB runs in bands of rows (started at the beginning, the threads load while the AV1 image is decoded) and the tiles of grids are decoded in parallel, with the same pixels. A 3840x2160 4:2:0 image decodes in 355 ms instead of 466 ms, a 6400x2880 grid of 20 tiles in 1.12 s instead of 2.68 s (8 threads). 4:2:0 and 4:2:2 images convert about 7% faster on one thread too. - Lossless JPEG XLs shown as they are (integer samples, no filters or upsampling) become RGBA bytes right away instead
of through float planes, in the threads, with an RCT of the colors undone in the same pass: the same pixels. A
3840x2160 image from
encodeJXL()decodes in 304 ms instead of 379 ms with 8 threads, 548 ms instead of 581 ms with one.
- Edge runtimes are tested: Cloudflare's workerd (with and without
nodejs_compat) and Vercel's Edge Runtime run every codec and the renderers bundled like deployments (tests/edge), and Chromium runs them bundled by Vite, with threads on a cross-origin isolated page and without on another (tests/browser).
Image.renderSVG()renders text (<text>,<tspan>,<tref>,<textPath>) with the fonts given asSVGOptions.fontsand the ones the SVG embeds with@font-face(TrueType, OpenType and WOFF in data: URIs, which win for their family names). Fonts are matched like browsers match installed ones:font-familyby the names in the fonts, thenfont-stretch,font-styleandfont-weightby CSS Fonts' algorithm; generic families take the first font fitting them (by its panose and fixed pitch flag), other families and text without one the first font, and characters a face doesn't have come from the other fonts. Laid out like resvg's (ported from usvg): chunks shaped as a whole (kerning and ligatures across spans, with each span's own font), the bidirectional algorithm,x/y/dx/dy/rotatelists,text-anchor,letter-spacing,word-spacing,textLengthandlengthAdjust,baseline-shift(sub,super, lengths),dominant-baselineandalignment-baseline,text-decorationin the colors of the elements declaring it, text along paths (startOffset, arc lengths measured like kurbo's), vertical text (writing-mode),font-variant: small-caps,font-kerning, thefontshorthand,xml:space, and color glyphs (COLR versions 0 and 1, CBDT and sbix bitmaps, SVG glyphs). Text is painted like shapes (gradients and patterns with the bounding box of the whole text, strokes,paint-order), and counts in clip paths and in bounding boxes. Variable fonts aren't supported yet. 359 of resvg's 431 text tests pass (with its test fonts), and 41 more of its other tests that have text; the others need variable fonts (14), resvg's default font size of 12 pixels (instead of 16), its cursive and fantasy fonts, or render as resvg doesn't (in ways browsers don't either, like conjuncts broken at a font change).encodeJXL({ quality, effort: 2 })stores text-like features that repeat (glyphs on the flat backgrounds of screenshots) once, in a reference frame, and adds them where they occur, like libjxl's patches (cjxl -e 7, ported from it): a page of text takes 62% fewer bytes (143x91 pixels: 1698 instead of 4458), screenshots 1-2% fewer, like with cjxl. Images without such features stay as they are; finding them takes about 0.3 s per megapixel.- Decoding converts images with embedded ICC color profiles to sRGB, like browsers show them (
DecodeOptions.applyColorProfile, defaulttrue, also forAnimation.decode()): PNGs (iCCP, unless an sRGB chunk is there too), JPEGs (APP2), WebPs (ICCP, also animated), AVIFs (colr prof/rICC, which then describe the RGB values instead of the nclx box) and TIFFs. Matrix/TRC profiles (RGB and gray; gamma, table and parametric curves) are applied relative colorimetric, colors outside sRGB clipped: Display P3 photos from phones, Adobe RGB and ProPhoto images no longer look washed out. The results are within 1 of LittleCMS's. Profiles with only lookup tables, CMYK profiles, gray profiles of color images (like libpng, which rejects them) and ones that are sRGB (like most cameras embed) leave the pixels as they are; converting a 1920x1080 image takes about 35 ms. font-sizeresolvesem,ex, percentages and keywords against the parent's size for all elements (lengths inemused the default 16 pixels when an ancestor's size was relative), andfont-weightbolderandlighter.Image.renderSVG()blends elements withmix-blend-mode(all 16 modes andplus-lighter, in CSS) and isolates groups withisolation: isolate, like resvg (the blend modes are the ones feBlend already used). All of resvg's 21 mix-blend-mode and 2 isolation tests pass, up from 4 and 1.Image.renderSVG()appliestransform-origin(keywords, lengths and percentages) to transforms of elements, clip paths, gradients and patterns; the root's transform turns around the center of its viewport, like resvg's. 21 of resvg's 23 transform-origin tests pass (the others have text), up from 3.Image.renderSVG()paints in the order ofpaint-order(fill, stroke and markers). 12 of resvg's 14 tests pass (the others have text), up from 6.Image.renderSVG()paints withcontext-fillandcontext-stroke: inside markers the paint of the shape they're on, inside<use>that of the element, in their coordinates and bounding boxes (gradients and patterns too). 13 of resvg's 16 tests pass, up from 2.Image.renderSVG()draws shapes without antialiasing forshape-rendering: crispEdgesandoptimizeSpeed, and fills the root's viewBox with itsbackground-color(in CSS).WebPEncodeOptions.alphaQuality(0-100, default 100, also for lossy animations andAnimation.webpEncoder()): below 100 the alpha plane of lossy images keeps fewer levels (2-16 up to 70, 16-256 above), like cwebp's-alpha_q(the same alpha values), so it compresses better: potato.png's alpha plane takes 1.3 kB at 50 instead of 1.9 kB, a gradient 4.3 kB instead of 16.7 kB.
Image.renderSVG(): zero length dashes (stroke-dasharray="0 40") at the start of a path get caps too, and square ones are turned along the path.Image.renderSVG(): shapes without a bounding box (horizontal and vertical lines and curves) painted with anobjectBoundingBoxgradient or pattern use its fallback color, and curves along an axis no longer count as having one.Image.renderSVG(): elements withdisplay: nonedon't count towards the bounding boxes of groups (clip paths, masks, gradients and filters inobjectBoundingBoxunits).Image.renderSVG():overflow="inherit"on markers and nested viewports takes the parent's value.Image.renderSVG(): path data ends at the first character that isn't part of it, instead of skipping it.Image.renderSVG(): line ends (CR LF, CR) read as line feeds, like XML parsers read them.- Hinting (
renderTextwithhinting) places points exactly like FreeType's TrueType interpreter (version 35): vectors, projections and moves in its integer arithmetic (2.14 unit vectors normalized like FT_Vector_NormLen, its rounding of products and movement factors, points touched only where they move),SLOOPwith 0,IPon the unscaled outline, composite glyphs' instructions measuring the hinted components at a scale of 1 and their origins left unrounded without instructions, andDELTAP/DELTACasking for more pairs than the stack holds. Over 18 fonts (DejaVu, Carlito, Noto, Source Sans, Amiri, M+, Open Sans...) at 8-48 pixels, and all 6190 glyphs of DejaVu Sans at 4 sizes, every point is where FreeType puts it; before, up to 30% of the glyphs of some fonts were off by fractions of a pixel, a few (DejaVu Sans Mono) by up to 4 pixels.
Decoding and encoding is faster, with identical results (images and files):
- JPEG decoding is about 14% faster (a 1920x1080 photo: 47 ms instead of 55 ms): YCbCr becomes RGB in a loop of its own, clamped by a lookup and stored a pixel at a time.
- JPEG encoding is about 10% faster: coefficients are stored in zigzag order with the position of each block's last nonzero one, so writing a block stops there instead of scanning all 64 (twice).
- PNG decoding is about 14% faster (a 1920x1080 photo: 36 ms instead of 41 ms): the Paeth filter keeps the neighbours of 3 and 4 byte pixels in variables, RGB rows become RGBA a pixel at a time.
- GIF decoding is about 14% faster (101 frames of 300x300: 40 ms instead of 46 ms): LZW strings are copied from where they were decoded before, instead of following a chain of codes back a byte at a time.
- Lossy WebP decoding is about 8% faster: upsampled pixels are stored a pixel at a time.
Image#region(x, y, width, height)copies a region into a new image and leaves the image as it is (likecropon a clone, without copying the whole image). It's clipped likecrop; on a frame it gives a plain image.Animation.webpEncoder(),Animation.gifEncoder()andAnimation.apngEncoder()(width, height, options) encode animations a frame at a time:await encoder.add(frame)for each frame (aFrame, or any image, with options like{ duration: 40 }that override a frame's), thenawait encoder.finish()for the file, the same oneencodeWebP(),encodeGIF()andencodeAPNG()make of the frames. Only the canvas and the frame before stay in memory (APNGs keep their frames compressed until the end: their pixel format depends on all of them): 120 frames of 1000x1000 take 159 MB instead of 1066 MB as anAnimation. Unoptimized WebPs with frames WebP can't store as they are (disposed to the previous frame, or disposed at odd offsets) store the canvas from the first of them on (encodeWebP()from the start), which looks the same.encodeWebP({ quality, sharpYuv: true })(images, animations andAnimation.webpEncoder) converts to YUV like libwebp's sharp conversion (cwebp -sharp_yuv, ported: the same files), which keeps colored edges sharp. It takes about twice the time at the default effort; photos get about as large (+0.3% on a 1920x1080 photo), images with many hard color edges a lot larger.Image.renderText()takes OpenType features:features: { tnum: true, smcp: true, liga: false, salt: 2 }turns features on for the whole text (tabular numbers, small caps, stylistic sets, ...), off (also the default ones like ligatures or kerning) or picks an alternate by its number, like HarfBuzz's user features (shaped exactly likehb-shape --features). Fonts without a feature ignore it.
Image.renderSVG()paints with patterns (<pattern>): tiles in user space or relative to the bounding box, their content in either,viewBoxandpreserveAspectRatio,patternTransform, attributes and children inherited throughhref. Like resvg, a tile is rendered once in device resolution and repeated (bicubic, or the nearest pixel where it's only translated); patterns painting their own content paint nothing. 32 of resvg's 33 pattern tests pass (the other one has text), up from 4.Image.renderSVG()draws markers (marker-start,marker-mid,marker-end, and themarkershorthand in CSS):orient(angles in any unit,auto,auto-start-reverse),markerUnits,refX/refY,markerWidth/markerHeight,viewBox, clipped unlessoverflowis visible; placed and oriented like resvg's (ported from usvg). Arcs are split into as many curves as resvg splits them into (more for large radii), which places the mid markers alike. 61 of resvg's 64 marker tests pass (the others have text or images), up from 11.Image.renderSVG()draws images (<image>) in data URIs: PNG, JPEG, GIF (the first frame), WebP and the other formatsImage.decode()reads, and SVGs (also gzipped, and with their own embedded images). They're fitted like resvg's:width/heightfrom the image where they're missing orauto,preserveAspectRatio(clipped withslice),image-rendering, sampled bicubically (the nearest pixel at their size). External files and URLs aren't loaded. Elements of embedded SVGs count towards the limit of 100000 elements rendered through references. Of resvg's 56 image tests, 17 pass the test suite's threshold, 37 without the frame around them, whose antialiasing differs (tiny-skia rounds edges to quarter pixels); most of the others load files.Image.renderSVG()applies filters (filter="url(#…)"and the filter functionsblur(),drop-shadow(),brightness(),contrast(),grayscale(),hue-rotate(),invert(),opacity(),saturate(),sepia()): all 17 primitives (feGaussianBlur, feOffset, feFlood, feComposite witharithmetic, feMerge, feDropShadow, feColorMatrix, feComponentTransfer, feTile, feBlend with all 16 modes, feMorphology, feConvolveMatrix, feTurbulence, feDisplacementMap, feDiffuseLighting and feSpecularLighting with distant, point and spot lights, feImage showing an element or an embedded image), with filter regions and subregions in both units,color-interpolation-filters(linear RGB by default), inputs and results, attributes throughhref. Like resvg (ported from it), filters run on 8 bit premultiplied pixels in the element's scale, rotation and skew applied afterwards; invalid filters hide the element. 396 of resvg's 408 filter tests pass, up from 81 (the others have text or load files, but for 3).- Optimized lossy WebP animations are smaller: like libwebp's, they leave out pixels that changed by up to a tolerance from the quality (3 per channel at quality 85, 1 at 100), and make unchanged pixels transparent only in whole 8x8 blocks. Making each unchanged pixel transparent gave speckled alpha masks where most pixels change a little (cross-fades), which could cost more than they saved. On six cross-faded radar-like animations at quality 85 the files are 2% larger to 13% smaller than before and 7-46% smaller than unoptimized ones, at 0.1 dB PSNR below unoptimized ones (before: 0.8-1 dB below); where nearly every pixel changes a little, they're 10x smaller.
- Lossless WebPs, and lossy ones with alpha (most optimized animations), could be undecodable since 3.7.0 (#82): a prefix group whose code had a single symbol of 256 or more (a length code) took a bit per symbol instead of none.
composite()with an opacity innormalmode is about 40% faster (a 1100x680 layer, two thirds at alpha 150: 7.6 ms instead of 12.6 ms): it takes the fast path with the source alpha scaled by the opacity, instead of the blend modes' per channel path. The results are the same, except for exact halves at opacities like 0.5, which now round like without an opacity (by 1 at most). Without an opacity it's 5-30% faster.
Image.encodeJXL({ quality, effort })encodes lossy JPEG XLs (VarDCT):quality(0-100) maps to the Butteraugli distance likecjxl -q(90 is cjxl's default, 100 is lossless); without it, images are encoded losslessly as before. The encoder follows libjxl: XYB, the varblock sizes and transforms of libjxl's search (effort1, the default, likecjxl -e 5; 2 also tries the 4x8 and AFV transforms; 0: 8x8 DCTs only, likecjxl -e 3), chroma from luma per 64x64 tile, libjxl's quantization, the weighted predictor for the LF, ANS, the edge preserving filter. Alpha is lossless. At the same SSIMULACRA2 score its files are about 1.5% smaller thancjxl -e 5's at effort 1 (3.5% at effort 2) and within 1-4% ofcjxl -e 7's (up to 9% for screenshots); effort 0 is within 1.5% ofcjxl -e 3. A 1920x1080 photo takes about 1 s at effort 0, 5 s at effort 1, 10 s at effort 2.
- Lossless WebPs (and the alpha of lossy ones) are smaller: the encoder groups blocks of 16 pixels by their statistics (prefix codes per group, like libwebp's entropy image), picks the color cache's size, delays matches by a pixel where the next one is longer, and predicts images of up to 256 colors instead of indexing them where that's smaller (smooth alpha). Lossless files are about 6% smaller (within about 7% of cwebp's), the alpha of lossy ones about 17% (at the default effort smaller than cwebp's). Lossless encoding takes about 65% longer.
AVIFEncodeOptionsis exported (with the newJXLEncodeOptions).
- Lossy JPEG XL decoding is about 15% faster (a 2048x1536 image: 0.73 s instead of 0.87 s), with identical results: XYB becomes sRGB bytes right away for images shown as they are (an exact lookup of linear light), Gaborish and the edge preserving filter's usual pass load fewer samples.
- AVIF encoding is about 8% faster: the SATD is unrolled, the transform types are tried on one prediction, 16x16 blocks whose coefficients take few bits aren't split further. The files are about as large as before.
Image.encodeAVIF()andImage.encodeJXL()takemetadatalike the other encoders: author, description, copyright, creation time and software (by default ImageScript and its version) as Exif. In AVIFs it is an Exif item describing the image, JPEG XLs get the container (with an Exif box; the codestream stays whatcjxl -e 1writes). 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;metadata: { software: '' }leaves out the software.
- Lossy WebP encoding defaults to
effort: 2instead of 4: about a quarter of the time (about 250 ms instead of 900 ms for a 1920x1080 photo) for files about 5% larger at a slightly lower quality (about 0.5 dB PSNR).effort: 4encodes like cwebp's default as before. - AVIF encoding also tries splitting blocks into halves (horizontal or vertical, from 64x64 to 16x16) where the whole block and its quarters cost about the same: files about 2% smaller at the default effort, encoding about 20% slower.
- AVIF encoding with
effort: 2also chooses smaller transforms within blocks (transform depth): files about 1% smaller (up to 4% for graphics), encoding about 10% slower. - PNG (and APNG) encoding leaves the rows 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), decided by compressing samples of the rows both ways. Photos stay as they were, encoding them takes about 8% longer.
- Lossy WebP encoding with
effort3 to 6 is about 25% faster: modes that can no longer beat the best one are dropped before their distortion and rate are computed. The files stay byte for byte what cwebp writes.
Image.encodeAVIF({ quality, alphaQuality, subsampling, effort })encodes AVIFs: an AV1 encoder of its own (still pictures, intra coding) whose syntax mirrors the dav1d based decoder, so dav1d decodes its files to exactly the encoder's reconstruction. It chooses partitions (64x64 to 8x8), the intra modes (with their angle deltas), chroma from luma, palettes (for screenshots and graphics) and the transform types by rate and distortion, quantizes the coefficients by rate and distortion, and picks the loop filter level and up to 8 CDEF strengths (per 64x64 block) by the filtered image. On photos its files are within a few percent of libaom's (avifenc's default speed) at the same PSNR, but encoding takes about 10 times as long.quality(0-100, default 60) maps to the quantizer like avifenc's -q; 100 is lossless (RGB, identity matrix). Greyscale images are encoded as monochrome. Transparency is a monochrome alpha image, lossless by default (alphaQuality); the colors of fully transparent pixels are smoothed (unless lossless), which costs fewer bits.subsampling: '4:4:4' (default) or '4:2:0'.effort(0-2, default 1): how many modes and partitions are tried.Image.encodeJXL()encodes lossless JPEG XLs: ported from libjxl's fast lossless encoder, the codestream is byte for byte whatcjxl -e 1writes on CPUs with AVX2 or NEON (libjxl's output depends on the CPU: it works in chunks of 16 pixels there, of 8 without SIMD, of 32 with AVX-512). Images of at most 511 colors besides black get a palette, others YCoCg; greyscale images are encoded as one channel, alpha only if the image has transparency. Unlike libjxl, two colors whose hashes collide within 8 pixels don't end up as one (libjxl then writes a palette that loses one of them; such images are encoded with YCoCg instead).
- JPEG XL decoding is about 25% faster: lossy 2048x1536 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 (a rational polynomial of the square root instead of a power).
Image.decodereads BMPs, ICOs and CURs, QOIs, Netpbm images (PBM, PGM, PPM, PAM, PFM) and Radiance HDRs, andImageTyperecognizes them ('bmp','ico','cur','qoi','pnm','hdr';ImageType.isBMPetc.):- BMPs of 1, 2, 4, 8, 16, 24, 32 and 64 bits, with channel masks of any size (scaled to full white, not shifted), alpha, RLE4 and RLE8 (pixels they skip are transparent, like in browsers), embedded JPEGs and PNGs, top-down rows, and OS/2's headers. Matches BMP Suite's reference images (32-bit channels wider than 8 bits within 1, they're rounded). Not supported: OS/2's Huffman and RLE24 compression.
- Icons and cursors decode their largest image (of those as large, the one with the most bits): bitmaps with their AND masks (32-bit ones with alpha, unless it's zero everywhere), or PNGs.
- Netpbm: plain and raw, samples of up to 16 bits, PAM's tuple types.
- Radiance HDRs: RGBE and XYZE, every orientation, both run length encodings.
Image.decodereads AVIFs, andImageTyperecognizes them ('avif',ImageType.isAVIF). The AV1 decoder is ported from dav1d and decodes exactly like it: 4:2:0, 4:2:2, 4:4:4 and monochrome, 8, 10 and 12 bits, every intra coding tool (palettes, intra block copy, filter intra, chroma from luma, quantizer matrices, lossless, 128x128 superblocks, tiles), every post filter (deblocking, CDEF, superres, loop restoration) and film grain. The container: the primary item or a grid of them, alpha (also premultiplied, or one per grid tile), the crop, rotation and mirroring properties (clap,irot,imir), the color description ofcolr, and the first frame of image sequences (also fromAnimation.decode). Converted to RGB like libavif does, so the pixels are libavif's; HDR AVIFs (PQ and HLG, BT.2020 and Display P3 primaries) are converted to sRGB withtoneMapping. Not supported: frames that use other frames (inter frames): image sequences only give their first frame, and of layered images whose shown layer is inter coded the highest intra coded layer is shown if it has the image's size (else they throw).Image.decodereads JPEG XLs,Animation.decodetheir animations (composited frames, durations, loop count), andImageTyperecognizes them ('jxl',ImageType.isJXL). Ported from jxl-oxide and libjxl: bare codestreams and the container (jxlc,jxlp), Modular (meta-adaptive trees, every predictor including the self-correcting one, reversible color transforms, palettes with delta and implicit colors, squeeze, LZ77) and VarDCT (every varblock type, chroma from luma, adaptive LF smoothing, chroma subsampled JPEG recompression), progressive passes and LF frames, Gaborish and the edge-preserving filter, patches, splines, noise, upsampling, cropped and blended frames and reference frames, alpha (also premultiplied), spot colors, CMYK, the orientation. XYB images are rendered to sRGB, HDR ones (PQ, HLG, or brighter than SDR white) tone mapped withtoneMapping; others stay in their color space, CMYK ones become their colors times black (like CMYK JPEGs). Every file of the JPEG XL conformance suite decodes within 1 of libjxl's djxl (as 8-bit sRGB; but djxl ignores black without a CMS). Not supported: ICC profiles aren't applied, previews are skipped.Image.encodeWebP({ quality })encodes lossy WebPs (VP8, with a losslessly compressed alpha plane if the image isn't opaque): ported from libwebp's encoder with its default settings, the VP8 bitstream is byte for byte the one cwebp writes for the same quality (0-100).effort(0-6, default 4) is cwebp's-m: 0-2 decide by distortion only and are several times faster, 5-6 search harder for smaller files. Without a quality WebPs stay lossless.Animation.encodeWebP({ quality, effort })encodes lossy frames the same way.DecodeOptions.toneMapping: HDRs', PFMs' and HDR AVIFs' and JPEG XLs' light can be brighter than (SDR) white. By default ('reinhard') the brightest parts are compressed like Reinhard's extended tone mapping operator does, the brightest pixel becomes white (without the operator's automatic exposure, which turns night scenes grey); images no brighter than white stay as they are.'clamp'clamps everything brighter than white instead. Either way the result is sRGB.Image.encodeBMP,Image.encodeQOIandImage.encodePNM:- BMPs with a palette of 2, 16 or 256 colors if an opaque image has at most that many, else 24-bit, or 32-bit with an alpha mask (a version 4 header) if the image has transparency
- QOIs byte for byte like the reference encoder (3 channels if the image is opaque)
- PBM for black and white images, PGM for greyscale ones, PPM, or PAM if the image has transparency
- TrueType hinting matches FreeType's in more fonts: SPVTL, SFVTL and SDPVTL set their vectors from the second point to the first (they pointed the other way), S45ROUND rounds to FreeType's 45° grid, ISECT and ALIGNPTS compute like FreeType's integer arithmetic. (Found by a test font using every instruction, compared with FreeType point for point.)
rand: the alternates picked at random are the ones HarfBuzz picks (its random state wraps around at 2^32).Image.renderSVG: spaces around presentation attribute values (fill=" url(#gradient) ") are ignored, as in CSS.
ResizeOptions.fast(defaulttrue): bilinear and area resizing (down to about a third of the size, and enlarging) round to 8 bits between the two passes, so both passes share their multiplications between two channels.fast: falsekeeps the more precise results of 3.2.1.- Opaque images: halving a 1920×1080 photo takes 12 instead of 15 ms, doubling it 61 instead of 80 ms. About 10-25% of the channel values differ from 3.2.1, by at most 1.
- Transparent images are premultiplied to 8 bits, like browsers do: halving takes 24 instead of 43 ms, doubling 103 instead of 260 ms. What shows once they're composited differs from 3.2.1 by up to about 2 steps (alpha by at most 2), the color of pixels that are almost transparent can differ more.
RotateOptions.fast(defaulttrue): rotations by angles that aren't multiples of 90° sample with 8-bit weights on 8-bit premultiplied pixels, two channels per multiplication, and skip the parts of each row outside the source. Rotating a 1920×1080 photo by 30° takes 51 instead of 119 ms (61 instead of 114 ms with alpha). Opaque pixels stay within 1 of 3.2.1 (0.5-2% of the values differ), what shows of translucent ones (also the faded edges) within about 2.5 steps.fast: falsekeeps the precise sampling.Image.renderTexttakes several fonts by priority ([text, emoji]): every character comes from the first font that has it, the first font sets the line height. Color glyphs keep their colors: COLR version 0 layers with CPAL palettes (e.g. Twemoji), CBDT/CBLC bitmaps (e.g. Noto Color Emoji, which now loads at all, it has no outlines) and sbix bitmaps (Apple's format). Invisible characters like joiners and variation selectors take no room, as in browsers.Image.renderTextshapes text in every script like browsers do (a port of HarfBuzz's OpenType shaping, glyph for glyph and position for position the same as HarfBuzz 14.6 on the tested texts):- right to left and bidirectional text (the Unicode Bidirectional Algorithm): Arabic, Hebrew and others, mixed with left to right text and numbers, brackets mirrored
- joining scripts (Arabic, Syriac, N'Ko, Mongolian, Adlam, ...), with presentation forms for fonts without shaping features
- Indic scripts (Devanagari, Bengali, Gurmukhi, Gujarati, Oriya, Tamil, Telugu, Kannada, Malayalam) with their conjuncts, reph and matra reordering, Khmer, Myanmar, Thai and Lao, Hangul syllables, and the scripts of the Universal Shaping Engine (Sinhala, Tibetan, Javanese, Balinese and many more)
- all GSUB and GPOS lookups: mark positioning (diacritics, vowel signs), cursive attachment, kerning, contextual substitutions and positioning; the kern table for fonts without GPOS kerning, and marks placed by their combining class for fonts without mark positioning
- emoji sequences (skin tones, flags, keycaps, families and other joined ones) become one glyph, from the first font that has all of their characters; the emoji variation selector picks a color glyph (❤️ in an emoji font rather than the text font's ❤)
- text wraps at the line break opportunities of the Unicode Line Breaking Algorithm (also in CJK text and before or after punctuation, not only at spaces), words wider than a line between grapheme clusters; each line is shaped and ordered by itself The Unicode data (properties of version 18.0) with the bidirectional algorithm and line breaking add about 39 kB (gzipped) to the package, the shaping about 39 kB.
Image.renderTextrenders COLR version 1 color glyphs (e.g. Noto-COLRv1): solid colors, linear, radial and sweep gradients (pad, repeat, reflect), glyph outlines as masks, transforms and all composite modes; variable paints with their default values. They are rendered at four times the size and scaled down, so layers sharing an edge don't let the ones below show through. Fonts with both prefer them over their other color glyphs, like browsers.Image.renderTextcomposes combining sequences (e + ◌́ to é, as in text from macOS) where the font has the composed character, and decomposes characters the fonts lack where one has their parts, like HarfBuzz. Fallback fonts are chosen per grapheme cluster (UAX #29).- Fallback fonts are only loaded when the text needs them, and glyphs (also decoded and scaled emoji bitmaps) are kept per font and size across calls.
DecodeOptions.maxPixelsandAnimationDecodeOptions.maxPixels(default 268,402,689, that's 16383², like sharp): larger images, and animations whose frames take more pixels together, throw a RangeError before anything that size is allocated. Tiny files declaring huge images (decompression bombs) could take gigabytes or kill the process: a 250-byte JPEG of 65500x65500, a 115 kB GIF with 5000 frames on a 4000x4000 screen.maxPixels: Infinitylifts the limit. TIFFs also can't take more than 8 bytes of samples per pixel of the limit, or tiles much larger than the image.
-
Lossless WebP encoding is about 40% faster and decoding about 15% faster (1920×1080 photo: 735 instead of 1220 ms, 82 instead of 95 ms), with identical output: the predictors run as one loop per mode instead of a switch per pixel, residual costs come from a table, and the LZ77 search hashes every pixel pair once.
-
Decoding PNGs takes less memory (an 8000x8000 RGBA image: 785 instead of 1272 MB at most): the rows are unfiltered in place, and
Image.decodekeeps the decoded pixels instead of copying them.Animation.decodedecodes still PNGs like images (10 times as fast for large ones), and composites APNG frames that replace pixels row by row.
Image.renderSVG: SVGs with long malformed styles,url()references, lengths or colors (e.g. a stylesheet of 200,000 characters without braces, which took a minute) parse in linear time instead of backtracking quadratically or worse in regular expressions. A style rule that isn't closed at the end of the stylesheet applies now, as in browsers.- NaN and infinite arguments throw a RangeError naming the argument instead of silently drawing black, transparent
or nothing: colors (
fill,setPixelAt,drawBox,drawCircle,trim,colorToAlpha,Image.gradient, the text and stroke colors,Image.rgbaToColorand the other color conversions, soImage.rgbToColor(NaN, 0, 0)is no longer black), coordinates and sizes (getPixelAt,crop,composite,drawBox,drawCircle,contain), angles and amounts (rotate,hueShift,roundCorners,fisheye,cropCircle), infinite factors (opacity,red,lightness,brightness,contrast,gamma, ...), the PNG compression and JPEG quality, the font size, frame durations and offsets, and loop counts. Numeric strings keep working.TextLayout'smaxWidthandmaxHeightacceptInfinity(no limit), NaN throws instead of meaning no limit. - The outline of
Image.renderText(strokeWidth) showed through translucent text, it now only shows around the glyphs (opaque text renders as before). - The outline of
Image.renderTextfollows the glyphs' antialiased edges instead of their pixels, so it is smooth rather than stepped, and it reaches exactlystrokeWidthbeyond the edge. Image.renderTextthrew for text without anything to show (empty, only spaces), it now renders a transparent pixel wide.- Damaged fonts no longer take unbounded memory or time: glyphs with contour end points that don't increase render empty (as in FreeType), composite glyphs can't contain themselves (also when hinting), and outlines have a size limit. Reading past the end of damaged or truncated font data throws "Font: damaged or truncated font data" instead of a RangeError, and text larger than 2^28 pixels throws.
- Glyph outlines reaching far outside their bitmap rasterize correctly (the parts outside used to change the slope of the edges inside).
- Damaged images throw "Image: damaged or truncated image data" (with the original RangeError as its cause) instead of RangeErrors from reading past their end. JPEGs with more than 4 components are rejected (each took memory the size of the image).
blurandsharpenhung with an infinite radius, they throw a RangeError now; huge finite radii take as long as small ones (blur(1e9)took minutes).Image.renderSVGagainst denial of service: elements rendered through<use>references multiplying each other ("billion laughs", a billion rectangles from a 1 kB file) are limited to 100,000 (like librsvg), dash patterns of more than a million dashes stroke solid (like Skia) instead of taking any amount of memory, and arbitrarily deep nesting no longer overflows the stack.
- Resizing is 2-4 times faster for opaque images with bilinear and area resizing (halving a 1920×1080 photo: 15 instead of 49 ms), and 20-40% faster otherwise: the arithmetic is integer, the vertical pass runs first, and for opaque images two channels share a multiplication. Results differ from 3.2.0 by at most 1 per channel (more only in the invisible color of pixels that are almost fully transparent).
clone()is about 50% faster.blur()is about 2.4 times faster (1920×1080 photo: 86 instead of 205 ms): the three box blurs run on the pixels as 32-bit words, two channels per sum. Results differ from 3.2.0 by at most 1 per channel (2 for radii above 10). Transparent images are blurred premultiplied in 8 bits, like browsers do: the color of pixels that are almost transparent can differ more, what shows when they're composited stays within 2 steps.- Decoding GIF animations is about 35% faster, with identical results: frames are composited from their color indices instead of an RGBA copy of each, and the composited frames aren't copied again.
- Enlarging with
RESIZE_AREAleft transparent rows and columns where a pixel's center fell right on the edge of the filter.
- Options objects: every method with optional arguments takes them as an object too, e.g.
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}),new Frame(10, 10, {duration: 50}),animation.encodeGIF({quality: 80}). Options can be left out or added without counting positions; the positional arguments keep working until 4.0. - Smaller animations:
encodeGIF(),encodeAPNG()andencodeWebP()store each frame as the area that changed since the frame before, with unchanged pixels transparent where that compresses better, and merge identical frames. Re-encoding 762 decoded animations (which have every frame as a full image) took 51% (GIF), 63% (APNG) and 54% (WebP) of the bytes in total; the less moves, the smaller a file gets, ones where everything moves stay about the same. Large animations encode faster, small ones up to about twice as slow. The files show the same animation as before, but don't decode to the same frames anymore:{optimize: false}stores the frames as they are. Animation.frames: the frames of an animation as an array, to use instead of the animation itself (see Deprecated). Changing it (push(),splice(), …) changes the animation, and it can be replaced as a whole.- Metadata in every format:
encodeJPEG(),encodeWebP(),encodeTGA()and, for animations,encodeGIF(),encodeAPNG()andencodeWebP()take the same metadata asencode()(now calledMetadata), as themetadataoption: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. Like PNGs, all files name ImageScript and its version as the software and include the creation time by default;{software: ''}leaves the software out. trim(color, options): removes borders of one color, like the empty space around rendered text or a solid background around a logo. Without a color, the top left pixel's is used;tolerancealso trims near colors (e.g. JPEG artifacts). Frames keep their position.
- GIF decoding is about 25% faster, with identical results: indices become colors through a lookup table, LZW strings are written without bounds checks unless they run past the image.
- SVG rendering is about 15-20% faster, with identical results: groups with opacity, clip paths, masks and nested viewports are composited only where they painted.
- PNG decoding is about 25% faster and encoding about 10% faster, with identical results: the paeth filter is computed without branches, 8-bit RGB images are converted without per-sample function calls.
- Lossless WebP encoding is about 15% faster, with identical files: backward reference candidates that can't be longer are skipped, the per-channel predictor helpers are unrolled.
- Resizing (bilinear, bicubic and area) is about 10% faster, with identical results.
- Animations as arrays:
Animation(andGIF) extendArray, soanimation[0],animation.lengthandanimation.map()work on the frames. In 4.0 they won't extendArrayanymore, which makes array methods unambiguous (map()can return anything, so it can't return an animation): useanimation.frames, an array of the frames that changes the animation when it's changed. PNGMetadata: renamed toMetadata, the old name stays an alias until 4.0.
- TGA encoding: run length encoded packets end with the row, as TGA 2.0 requires. Before, packets could span rows, which some readers reject (Pillow fails with "buffer overrun").
- PNG text metadata: text that isn't Latin-1 (like
©is, butⒸor emoji aren't) is written intoiTXtchunks as UTF-8 instead oftEXt, where readers showed it garbled; text chunks come before the image data, where all readers look for them. - An invalid
creationTimethrows aRangeErrorinstead of writing "Invalid Date".
- WebP decoding: lossy, lossless, with alpha and animated (#19, #20). Still images decode with
Image.decode(), animated ones withAnimation.decode(). The output is bit-exact with libwebp (checked against libwebp's test vectors and their reference checksums). - WebP encoding (lossless):
Image.encodeWebP(), typically 20-30% smaller than PNG. - APNG decoding and encoding (#6):
Animation.decode()reads animated PNGs (broken animations fall back to the default image, like browsers do),Animation.encodeAPNG()writes them with full alpha.Image.decode()still returns an APNG's default image. Animation, the base class ofGIF: an array of frames that decodes GIF, APNG and WebP (Animation.decode()) and encodesencodeGIF(),encodeAPNG()andencodeWebP().GIFkeeps its API.decode()returns animated WebPs asAnimation;ImageTyperecognizes WebP (isWebP(),getType() === 'webp').- TGA decoding and encoding (#21): color mapped, truecolor and greyscale, uncompressed and run length encoded, all
four origins.
Image.decode()recognizes TGAs by their header (they have no magic number),encodeTGA()writes run length encoded 24/32 bit files. colorToAlpha(color, options)(#39): makes a color transparent like GIMP's "Color to Alpha", antialiased edges become translucent instead of keeping a fringe of the removed color. Without a color, the top left pixel's is used. The color is removed everywhere in the image, not only from a connected background.- TrueType hinting (#31):
renderText()runs the hinting instructions of TrueType fonts, which align stems and edges with the pixel grid for crisper small text, like Windows renders it. Hinted outlines match FreeType's classic interpreter point for point, glyphs of hinted fonts are placed on whole pixels. Fonts without instructions (and CFF/OpenType outlines) render as before;renderText(..., {hinting: false})turns it off.
GIF: useAnimation, which works the same and also handles APNG and WebP (new GIF(frames).encode()becomesnew Animation(frames).encodeGIF(),GIF.decode()becomesAnimation.decode()).GIFwill be removed in 4.0.
- Text rendering: glyph outlines touching the left edge of their bitmap could come out inverted (a rasterizer rounding issue); hinting made it common
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.
- Node.js 22.13 or newer is required. The package is ES modules only;
require('imagescript')keeps working through Node'srequire(esm). - All coordinates are 0-based.
getPixelAt,setPixelAt,getRGBAAt, iteration,fillcallbacks,drawBoxanddrawCircleused 1-based coordinates whilecropandcompositeused 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, useencode()(PNG) orencodeJPEG(). invertSaturation()andinvertHue()were removed.invertHue()mirrored the hue (h -> 1 - h); for the complementary color usehueShift(180).- The experimental
imagescript/v2API was removed. TextLayoutalignment options are named correctly:horizontalAligntakesleft/center/right,verticalAligntakestop/middle/bottom. Values passed under the old (swapped) names are still accepted. Alignment andwrapStyle: '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.decodereturns 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. PassImage.RESIZE_NEAREST_NEIGHBORfor pixel art; it now samples the pixel centers, which shifts its output by up to a pixel.RESIZE_AUTOrounds the computed side instead of flooring it. drawCircle(),cropCircle()androundCorners()are antialiased.drawCircle(x, y, r)is centered on the center of pixel (x, y) and has a radius of exactlyr.cropCircle(max, feathering):featheringis 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.#fb0000for 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.disposalModereturns the mode's name ('keep', ...), matching theFrame.DISPOSAL_*constants and what it is set to; it used to return the numeric id.invertValue()inverts the HSV value (the brightest channelvbecomes255 - v, black becomes white). It used to invert HSL lightness, whichinvertLightness()does now.rotate()is documented as counterclockwise, which it always was.averageColor()weights pixels by their opacity.
rotate(90)/rotate(270)scrambled non-square images (#29); free rotation lost the image edgescomposite()darkened semi-transparent pixels drawn onto transparent onescrop()read pixels from the next row when the region exceeded the imagedrawBox()threw when the box was outside the imagecropCircle(max)ignoredmaxroundCorners()was asymmetric and did nothing to the right/bottom corners for fractional radiiFrame.resize()returned a frame without its duration, offsets and disposal mode;Frame.clone()returned an ImageGIF.slice()without an end,GIF.map()andGIF.filter()threwGIF.resize()stretched partial frames to the full sizeencode({...metadata})ignored the metadatafisheye()shifted the image by a pixel- HSL conversions truncated instead of rounding, so
lightness(1),saturation(1)orhueShift(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.bitmapleftgetPixelAt()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
blur(radius)(gaussian, radius = standard deviation like CSS'blur()) andsharpen(amount, radius)(unsharp mask)brightness(factor),contrast(factor),gamma(gamma),greyscale()(ITU-R 601 luma, like Pillow) andsepia()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) andadd- Outlined text:
renderText(font, size, text, color, layout, {strokeWidth, strokeColor}) invertLightness(), which does whatinvertValue()used to do (invert HSL lightness)encodePNG(), an alias ofencode()- 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 aDataView- Parameter names describe what they are:
red/green/blue(factor),renderText(font, size, ...),fisheye(strength) - Resize modes
Image.RESIZE_BILINEAR(default),Image.RESIZE_BICUBICandImage.RESIZE_AREA, all usable withresize,scale,contain,fit,coverandGIF.resize
- 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.