Repository navigation
Conversation
The new decoder in formats/png.js supports all color types and bit depths (1-16), tRNS transparency (including grey/RGB color keys, which png.wasm ignored), adam7 interlacing and chunk checksum validation. It passes the PngSuite: output matches Pillow except where Pillow itself is wrong (16-bit grey clipping, unscaled tRNS for low bit depth grey). zlib (fflate) and crc32 are split out of the png/node.js bundle into formats/ so the GIF and TIFF codecs can reuse them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
GIF.encode no longer uses the native codecs addon (and with it libimagequant), GIF.decode no longer uses gif.wasm. - formats/quantize.js: exact palette when a frame has few enough colors, otherwise weighted median cut + k-means refinement with floyd-steinberg dithering. quality scales the palette size from 8 colors (1) to 256 (90+) - formats/gif.js: LZW encoder/decoder, local color tables, transparency, disposal modes, interlacing, NETSCAPE/ANIMEXTS loop counts - GIF.decode now reads the loop count instead of always returning -1, clips frames to the logical screen and tolerates truncated files - encoded output decodes pixel-identical in Pillow - the decoder passes 72/84 of the pygif test suite; the rest expect images without delay to be merged into one frame (browsers and ImageScript keep one frame per image), malformed files to yield no frames (we decode leniently, like browsers) or GIF87a files to loop forever Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Image.decode no longer uses jpeg.wasm and Image.encodeJPEG no longer uses the native codecs addon. Decoder: baseline, extended and progressive huffman JPEGs, any sampling factors, restart intervals, greyscale/YCbCr/RGB/CMYK/YCCK (Adobe inverted CMYK included). IDCT, fancy upsampling and color conversion follow libjpeg, so the output is bit-exact with libjpeg-turbo (Pillow) on every test image, where jpeg.wasm was off by up to 7. Arithmetic coded, lossless and 12-bit JPEGs are rejected with a clear error. Encoder: baseline with IJG quality scaling, 4:2:0 up to quality 90 and 4:4:4 above, optimized huffman tables. Files are ~20% smaller than the native encoder's at equal PSNR, encoding is ~2x slower. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Image.decode no longer uses tiff.wasm, which failed on every file of the
libtiff test images and on everything Pillow writes ("memory access out of
bounds"). Big endian TIFFs weren't even detected as TIFF.
formats/tiff.js decodes the first image of classic and BigTIFF files in
both byte orders: strips and tiles (including pre-6.0 tiles stored in the
strip tags), chunky and planar data, no/LZW (new and old style)/deflate/
PackBits/JPEG compression, horizontal and floating point predictors,
1-32 bit integer and 32/64 bit float samples, greyscale, palette, RGB, CMYK
and YCbCr (including subsampled) with associated or unassociated alpha.
Floats in [0, 1] or [0, 255] are used as is, other ranges are normalized.
CCITT fax, old-style JPEG, ThunderScan and LogLuv are rejected with an
error naming the compression.
Output matches Pillow on the libtiff test images and a generated corpus,
except where Pillow clips 16-bit greyscale (verified against the raw
samples instead) and off-by-one rounding for YCbCr and premultiplied alpha.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Image.renderText no longer uses font.wasm. - formats/font.js parses TrueType (glyf, including composite glyphs) and OpenType CFF fonts (including CID-keyed fonts) and the first font of a collection, with cmap formats 0, 4, 6 and 12 - formats/text.js rasterizes outlines with a signed area coverage rasterizer (the font-rs algorithm fontdue is based on) and lays out text Layout metrics match the previous renderer (baseline at ceil(ascender), line height ceil(ascender - descender + lineGap), ceiled advances), so images keep their size and text its position; glyph coverage differs by less than 1/255 on average. Fonts are parsed lazily, rendering with an 11MB CJK font takes ~2ms instead of ~1.4s. TextLayout options that were silently ignored now work: horizontal alignment (verticalAlign: center/right), wrapStyle 'char', and vertical alignment (horizontalAlign: middle/bottom) choosing which lines remain when maxHeight cuts lines off. Newlines no longer widen a line as if they were a glyph. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Image.renderSVG no longer uses svg.wasm (resvg 0.13). formats/svg.js renders static SVG: shapes, paths (including arcs), transforms, nested svg/use/symbol with viewport clipping, presentation attributes, style attributes and <style> sheets (type, class, id, attribute and :first-child selectors with descendant/child/sibling combinators, !important), currentColor, linear and radial gradients, strokes with joins, caps and dashes, nonzero/evenodd fills, group opacity, clip paths and luminance/alpha masks, conditional processing (switch, systemLanguage) and DOCTYPE entities. Text, images, filters, patterns and markers are not rendered. Coverage is computed with 16 sub-scanlines per pixel and exact horizontal span coverage. On resvg's own test suite (951 cases for shapes, painting, paint servers, structure and masking) it matches 687 reference images, the old resvg build 682, although patterns and images (64 of the old passes) are left out. On the repo's test SVGs the output differs from resvg only in anti-aliasing at the edges. The old build crashed on the new tests/svgs/features.svg. Rendering is slower than the wasm build for large outputs (~130ms vs ~25ms at 2048px), 2-25ms for icon sizes up to 512px. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Every codec and renderer is now plain JavaScript, so the compiled blobs go:
wasm/ (gif.wasm, jpeg.wasm, svg.wasm, tiff.wasm) and codecs/ (the
@imagescript/codecs native addons for 5 platforms plus its wasm stubs).
- the experimental v2 API (v2/index.js) uses the formats/ codecs instead
of the blobs, same interface
- GIF frame compositing is shared between GIF.decode and v2 Animation
- WebP encoding is dropped: Image.encodeWEBP and v2 encode('webp') now
reject with an error pointing to PNG/JPEG (breaking change)
- README describes the codecs as pure JavaScript
The npm package shrinks from 3.0 MB (71 files) to 152 kB (43 files).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
The image operations no longer go through the v2 framebuffer bundle, which had several bugs: - rotate(90) and rotate(270) scrambled non-square images, leaving black gaps (#29) - free rotation dropped the first row and column of the source and wrote one pixel past every output row - composite() ignored the destination alpha, so semi-transparent pixels drawn onto transparent ones turned dark (white at 50% became grey) - cropCircle(max) ignored max rotate() is documented as counterclockwise, which it always was. tests/rotate.js wrote its targets before comparing against them, so it could never fail; it now only writes them with OVERWRITE_TEST. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
The v2 API was an unfinished, undocumented port with its own framebuffer bundle; ImageScript no longer uses any of it. png/ (the old PNG codec sources) and bench/ (benchmarks against external wasm packages) only existed for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Bugs: - crop() read past the row (and from the end of the buffer for negative offsets) when the region exceeded the image; it is now clipped and throws if it doesn't overlap the image - drawBox() threw when the box was below the image - averageColor() divided unweighted color sums by the alpha sum, which was wrong (and could overflow) for semi-transparent images - dominantColor() never reached its fallback; transparent pixels are now ignored - Image.gradient() returned signed colors, unlike rgbaToColor() - encode(metadata) treated the metadata as the compression level - Frame.resize() returned a new frame with default duration, offsets and disposal mode; Frame.clone() returned an Image - GIF.slice() without an end threw, map/filter threw (Symbol.species) - GIF.resize() stretched partial frames to the full size and produced fractional offsets; frames are now scaled proportionally - fisheye() mixed 1- and 0-based indices, shifting the result and wrapping the last row - TextLayout's verticalAlign set the horizontal alignment and vice versa; the names are fixed, old values are still accepted under either name ImageScript.d.ts was not valid TypeScript (async and generators in an ambient context, Buffer without @types/node, incompatible overrides), which skipLibCheck hid. It is rewritten to match the API, tsconfig no longer skips it and CI type-checks it. tests/regressions.js covers all of the above. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Pixel access, iteration, fill callbacks, drawBox and drawCircle used 1-based coordinates while crop and composite used 0-based offsets. All of them are 0-based now, (0, 0) being the top left pixel (breaking). roundCorners() is rewritten as well: corners were asymmetric by a pixel and fractional radii (like the default min(width, height) / 4 for odd sizes) left the right and bottom corners untouched. The changed targets are the old ones shifted by one pixel, except for readme.png which also has rounded corners. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
The library is now written in TypeScript (strict, noImplicitOverride) and
built to dist/ as ES modules with generated type declarations, replacing
the hand-written ImageScript.d.ts. CommonJS consumers keep using
require('imagescript') through require(esm), which needs Node 22.12; the
engines field and CI matrix (22, 24, 26) follow that. Node 20 is EOL.
- src/ mirrors the old layout: codecs/ (png, gif, jpeg, tiff, zlib, crc,
quantize), render/ (font, text, svg), the public classes in separate
modules and index.ts as the entry point
- no var anywhere (zlib was the last fflate-style code)
- no runtime globals besides ES2022: UTF-8 handling has its own helpers
instead of TextEncoder/TextDecoder
- PNG and TIFF inflate through DecompressionStream (native zlib) when the
runtime has it, falling back to the JS inflate otherwise; 2048x2048 PNG
decoding goes from 202ms to 119ms
- the font cache is keyed by the font data object instead of its
ArrayBuffer and offset, which pooled Node buffers could reuse for a
different font
- tests run against the build; all PNG/GIF/JPEG targets are byte-identical
to the JS version, and the reference corpora (PngSuite, pygif, resvg,
libjpeg-turbo and libtiff images) give the same results as before
- CHANGELOG.md lists the 3.0 breaking changes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
median cut works on typed arrays with a stable merge sort instead of sorting point objects, and nearest color lookups search outwards along the palette's widest axis instead of scanning every entry. the output is byte-identical; gif encoding is 1.2-2.3x faster. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
- hsl conversions round instead of truncating and wrap negative hues, so lightness(1)/saturation(1) keep the image and hueShift(-300) works - composite() and rotate() rounded every channel up (+0.5 on a clamped array, which rounds by itself) - cover()/contain() could miss the requested size by a pixel - node Buffers are unwrapped to plain Uint8Arrays, their slice() doesn't copy: decoding an uncompressed tiff with a predictor modified the input. the font cache is keyed by memory region so it still hits - nested svg entities could exhaust the heap with a 1 kB file, the expansion is now capped - inflate threw on stored blocks past the expected size - the test runner's timeout never fired - invert(), dominantColor() and rotate() avoid per-pixel allocations Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
…and png color types - resize modes RESIZE_BILINEAR (new default), RESIZE_BICUBIC and RESIZE_AREA: separable convolution in premultiplied alpha with the filter widened by the downscale factor (like Pillow), so shrinking doesn't alias. nearest neighbor samples pixel centers - drawCircle, cropCircle and roundCorners antialias their edges; cropCircle was off by half a pixel and its feathering faded the whole circle, it now softens the outer share of the radius - dominantColor returns the mean of the largest median cut / k-means cluster instead of an HSL histogram bucket corner (#fb0000 for red), and only discards colors as black/white when all channels are extreme (navy was dropped as black) - text is placed with subpixel precision (quarter pixel glyph bitmaps) and kerned via GPOS pair adjustments or the kern table; positions match harfbuzz for all test fonts - png encoding picks the smallest lossless color type (palette with 1-8 bit indices, grey/rgb, alpha only when needed) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
every row gets the filter type with the smallest sum of absolute differences (libpng's heuristic). palette images and level 0 stay unfiltered, like libpng recommends. at the default level photos get about 25% smaller (698 -> 523 kB for a 638x638 photo, 66 -> 79 ms) and smooth gradients up to 97% smaller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
performance (output unchanged unless noted): - png compression and decompression use node:zlib through process.getBuiltinModule when available (node, bun, deno), with the JS deflater/DecompressionStream as fallback. CompressionStream was measured and not used: it is fixed to zlib level 6 and twice as slow as fflate level 1 on filtered photos. 4 MP encode 640 -> 270 ms - png rows are paeth filtered instead of picking a filter per row: half the cost, sizes within 0.1% - png unfilter handles edges in separate loops, converters step through the output instead of calling a closure per pixel - hsl functions convert in place without allocations, wrap hues without float modulo and cache results of recent colors: 340 -> ~100 ms - rotate inlines its bilinear taps: 970 -> 250 ms - composite copies opaque pixels as 32-bit words api: - invertLightness(), invertValue() is deprecated - encodePNG() as an alias of encode() - invertHue() shifts to the complementary hue instead of mirroring it - gif frame durations are rounded to centiseconds instead of truncated - parameter names: red/green/blue(factor), renderText(font, size), fisheye(strength) tests compare decoded pixels instead of encoded bytes, so they don't depend on the runtime's compressor Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
instead of deprecating it as an alias of invertLightness: the brightest channel v becomes 255 - v and the others scale along, so hue and hsv saturation are kept and black turns white. the old hsl lightness behavior is invertLightness. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
- jpeg (exif app1) and tiff orientation tags are applied when decoding,
verified against Pillow's exif_transpose for all eight orientations;
Image.decode(data, {applyOrientation: false}) opts out
- rgbaToColor/rgbToColor round and clamp instead of wrapping
- Frame.disposalMode returns names like the constants it is set with,
Frame.DISPOSAL_ANY added
- assigning image.bitmap keeps the internal views in sync (they kept
pointing at the old pixels), and validates the size
- options objects for cropCircle, dominantColor and rotate
- ImageType checks accept any binary data
- node >= 22.13: 22.12 prints an ExperimentalWarning on require(esm);
ci tests the minimum version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
new:
- blur(radius): gaussian with radius = standard deviation; three
running-sum box passes for radius >= 2 (cost independent of the
radius), a real kernel below. premultiplied, so transparent pixels
don't bleed. within a few levels of Pillow's GaussianBlur
- sharpen(amount, radius): unsharp mask, matches Pillow's UnsharpMask
within a few levels
- brightness, contrast, gamma (lookup tables), greyscale (601 luma,
identical to Pillow's convert('L')) and sepia
- composite(source, x, y, {opacity, mode}) with the css mix-blend-mode
set plus add (w3c compositing model; within 1 of Pillow's ImageChops)
- renderText(..., {strokeWidth, strokeColor}): outline from an exact
euclidean distance transform, independent of the stroke width
removed: invertSaturation(), invertHue() (use hueShift(180)) and the
encodeWEBP() stub
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
docs are built with typedoc (npm run docs) and deployed by a workflow on every published release. pages of the old jsdoc site redirect to their new location, anchors included. every exported symbol is documented now (typedoc validates it), overloads carry their own docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
a published release with the tag v<version> is checked by scripts/release-check.js (tag matches package.json, valid semver, newer than the published versions of its major, changelog section, prerelease flag matches), tested with the full node matrix, published through npm trusted publishing with provenance and then the docs are deployed. prereleases go to their own dist-tag (beta, rc, next), fixes for older majors to v<major>-latest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
…larations Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJ77uRbherrfubLZTBneQz
about 2.4x the throughput of the byte-wise loop, which mostly shows in uncompressed PNG encodes and in the checksum verification when decoding Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J
the Exif parser moves out of the JPEG codec so both share it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J
jsr.json publishes the TypeScript sources as @matmen/imagescript, its version is kept in sync by the build. The release workflow publishes to JSR after npm through GitHub OIDC, the release check makes sure the versions match and JSR doesn't have the version yet Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J
the slice-by-16 crc32 comes from Arjun Barrett's gist, parts of the JPEG codec are translated from libjpeg (IJG) and follow pdf.js' decoder, the glyph rasterizer is translated from font-rs. THIRD_PARTY_NOTICES.md lists them with their licenses (full Apache-2.0 text and libjpeg's README in licenses/), the derived files say so in /*! */ headers that survive the build, which used to strip fflate's notice from dist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ImageScript 3.0: every codec and renderer rewritten in TypeScript. No more WebAssembly, native binaries or npm dependencies.
The Rust sources of the old WebAssembly modules are lost, and the native addons bundled GPL code, so none of that could be rebuilt or maintained. v3 rewrites everything in plain TypeScript:
@matmen/imagescriptis now the same package as on npm and is published with every release.The full list of changes, with migration notes, is in CHANGELOG.md.
Breaking changes (short version)
require()keeps working throughrequire(esm)getPixelAt,setPixelAt, iterators,fill,drawBox,drawCircle, ...)encodeWEBP(),invertSaturation(),invertHue(), theimagescript/v2APITextLayoutalignment option names are fixed; the old values are still acceptedapplyOrientation: false)invertValue()inverts the HSV value; the old behavior is nowinvertLightness()New
blur(),sharpen(),brightness(),contrast(),gamma(),greyscale(),sepia()composite()with opacity and blend modesCodecs
Testing
Releasing
Publishing a GitHub release
v<version>:jsr.json, and that the version isn't on npm or JSR yetPrereleases go to the npm dist-tag
next/beta/...Closes #12, closes #18, closes #23, closes #29, closes #30, closes #33, closes #34, closes #37, closes #38, closes #40, closes #41, closes #44, closes #45
Probably fixes #42 (the JPEG decoder was rewritten; the file from the report isn't attached)