Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
bb3abb1
replace png.wasm with a pure js png decoder
matmen Oct 13, 2026
85ae038
pure js gif encoder and decoder
matmen Oct 13, 2026
7f490dc
pure js jpeg encoder and decoder
matmen Oct 13, 2026
e73ca3d
pure js tiff decoder
matmen Oct 13, 2026
620d37e
pure js font rendering
matmen Oct 13, 2026
2d1a5f5
pure js svg renderer
matmen Oct 13, 2026
26231d2
remove all wasm and native binaries
matmen Oct 13, 2026
b23b104
fix rotation, compositing and cropCircle
matmen Oct 13, 2026
a61dbc8
remove v2, png and bench
matmen Oct 13, 2026
ddef465
fix api bugs and invalid type definitions
matmen Oct 13, 2026
c934df5
use 0-based coordinates everywhere
matmen Oct 13, 2026
bd285e8
port to typescript, esm only, node 22.12+
matmen Oct 13, 2026
b16b41e
speed up the gif quantizer
matmen Oct 13, 2026
e6eacb6
fix rounding, input mutation and svg entity expansion
matmen Oct 13, 2026
81438fb
filtered resizing, antialiased shapes, kerning, better dominantColor …
matmen Oct 13, 2026
2b970a5
filter png rows adaptively for non-palette images
matmen Oct 13, 2026
ae3e551
native zlib, faster pixel ops, api naming cleanup
matmen Oct 13, 2026
e35b8fe
make invertValue invert the hsv value
matmen Oct 13, 2026
a409e97
apply exif orientation, clamp colors, api consistency for 3.0
matmen Oct 13, 2026
30d575f
blur, sharpen, rgb adjustments, blend modes and outlined text
matmen Oct 13, 2026
2add5f4
readme: install, commonjs usage, v3 upgrade notes
matmen Oct 14, 2026
9c3b79d
typedoc documentation, published to github pages on release
matmen Oct 14, 2026
110547f
publish github releases to npm
matmen Oct 14, 2026
039f89d
sync src/version.ts on npm version
matmen Oct 14, 2026
e7b1c50
run the tests on every branch
matmen Oct 14, 2026
3865057
update github actions to their node 24 versions
matmen Oct 14, 2026
955b52b
smaller package: no comments in the built js, no internals in the dec…
matmen Oct 14, 2026
5a29895
slice-by-16 crc32 (#12)
matmen Oct 14, 2026
12a691d
orient PNGs by their eXIf chunk, ImageMagick's Exif text or XMP
matmen Oct 14, 2026
04131f6
publish to JSR alongside npm (#33, #38)
matmen Oct 14, 2026
6cbac7f
credit third party code
matmen Oct 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: docs

# publishes the documentation to GitHub Pages (imagescript.matmen.dev). release.yml runs it after a release reached
# npm, so the site documents what is on npm; can also be run by hand from the Actions tab
on:
workflow_call:
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24.x

- run: npm install
- run: npm run docs

- uses: actions/configure-pages@v6
- uses: actions/upload-pages-artifact@v5
with:
path: docs

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v5
19 changes: 14 additions & 5 deletions .github/workflows/node.yml
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
name: node

# tests every pushed branch, and pull requests from forks (pull requests from branches of this repository are
# already tested by their push); release.yml runs it before publishing
on:
push:
branches: [dev, master]
branches: ['**']
pull_request:
branches: [dev, master]
workflow_call:

# a newer push to the same branch cancels the tests of the previous one
concurrency:
group: node-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name != 'release' }}

jobs:
build:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
runs-on: ${{ matrix.os }}
name: node ${{ matrix.version }} | ${{ matrix.os }}

strategy:
matrix:
version: [20.x, 22.x, 23.x, 24.x]
version: [22.13.0, 22.x, 24.x, 26.x]
os: [macos-latest, ubuntu-latest, windows-latest]

steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.version }}

- run: npm install
- run: npm test
85 changes: 85 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
name: release

# publishing a GitHub release with the tag v<version> publishes that version to npm and JSR:
# the release is checked (tag, semver, changelog, jsr.json, not yet on npm/JSR), tested on every supported platform,
# published to npm with provenance through trusted publishing and to JSR through GitHub OIDC (no tokens),
# then the docs are deployed
on:
release:
types: [published]

permissions:
contents: read

concurrency:
group: release
cancel-in-progress: false

jobs:
check:
runs-on: ubuntu-latest
outputs:
dist-tag: ${{ steps.check.outputs.dist-tag }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24.x

- id: check
run: node scripts/release-check.js "$TAG" "$PRERELEASE"
env:
TAG: ${{ github.event.release.tag_name }}
PRERELEASE: ${{ github.event.release.prerelease }}

test:
needs: check
uses: ./.github/workflows/node.yml

publish:
needs: [check, test]
runs-on: ubuntu-latest
environment:
name: npm
url: https://www.npmjs.com/package/imagescript?activeTab=versions
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24.x
registry-url: https://registry.npmjs.org

- run: npm install
# prepack builds dist; trusted publishing needs npm 11.5.1 or newer, which node 24 ships
- run: npm publish --provenance --access public --tag "$DIST_TAG"
env:
DIST_TAG: ${{ needs.check.outputs.dist-tag }}

jsr:
needs: publish
runs-on: ubuntu-latest
environment:
name: jsr
url: https://jsr.io/@matmen/imagescript/versions
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v7
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
# publishes the TypeScript sources as they are, JSR authenticates the workflow through the linked repository
- run: deno publish

docs:
needs: [check, publish]
if: needs.check.outputs.dist-tag == 'latest'
permissions:
contents: read
pages: write
id-token: write
uses: ./.github/workflows/docs.yml
6 changes: 3 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ old/
coverage/
.DS_Store
.nyc_output/
.node_modules/
bench/node_modules/
bench/package-lock.json
node_modules/
dist/
docs/
5 changes: 0 additions & 5 deletions .npmignore

This file was deleted.

106 changes: 106 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Changelog

## 3.0.0

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

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

### Breaking changes

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

### Fixes

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

### New

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

### Codecs

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