Install
The Node entry requires Node.js 22 or newer. Modern browser applications use purejsimage/browser. The published package has no runtime dependencies, native addons, external binaries, or required WebAssembly modules.
npm install purejsimageThe package is ESM and includes its TypeScript declarations. Import the core API and each codec from its own entry point so your application loads only what it accepts.
Run in a browser
The browser entry uses the same codecs, limits, immutable pipeline, and pixel kernels as Node. Its platform adapter accepts DOM-native files and returns web-native output without Buffer polyfills or Node built-ins.
import { createImageLibrary } from 'purejsimage/browser'
import { jpegCodec } from 'purejsimage/codecs/jpeg'
import { pngCodec } from 'purejsimage/codecs/png'
const images = createImageLibrary([jpegCodec, pngCodec])
const image = await images.open(file)
const output = await image
.autoOrient()
.resize({ width: 1200, withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBlob()
preview.src = URL.createObjectURL(output)Use File/Blob, ArrayBuffer, Uint8Array, or a custom ImageSource for input. Use toUint8Array(), toBlob(), or toSink() for output. Browser security does not permit arbitrary local path strings, so toFile(path) is Node-only.
CompressionStream and currently accepts the default compressionLevel: 6 only. Other levels fail with UNSUPPORTED_OPERATION.Open scientific rasters in a browser
GSF and paired ENVI inputs use the portable scientific dataset API rather than the ordinary photographic codec registry. Pass a GSF File/Blob directly, or pass the ENVI header and binary as two independent browser inputs. Native samples, physical units, no-data markers, channel names, and wavelengths remain quantitative until an explicit renderer produces display pixels.
import { createScientificLibrary, renderScientificPlane } from 'purejsimage/scientific'
import { createScientificFileContext } from 'purejsimage/scientific/browser'
import { enviReader } from 'purejsimage/scientific/readers/envi'
import { gsfReader } from 'purejsimage/scientific/readers/gsf'
const science = createScientificLibrary({ readers: [gsfReader, enviReader] })
const document = await science.open(createScientificFileContext(gsfFile))
const surface = await document.openDataset(document.datasets[0].id)
const display = await renderScientificPlane(surface, {
plane: { displayAxes: ['x', 'y'], fixedIndices: [] },
range: { mode: 'percentile', low: 1, high: 99 },
palette: 'viridis',
relief: { azimuth: 315, elevation: 45, strength: 0.5 },
})
const cubeDocument = await science.open(createScientificFileContext(headerFile, { companions: [binaryFile] }))
const cube = await cubeDocument.openDataset(cubeDocument.datasets[0].id)Keep expensive range scans and rendering in a Web Worker, transfer final display bytes to the main thread, and use Canvas only as that final display surface. The core reader and renderer do not depend on Canvas. The Scientific Raster Explorer demonstrates local file selection, paired-file ENVI opening, wavelength controls, false-color composition, relief, and honest binary-read timing entirely client-side. The OME-Zarr WSI viewer demonstrates measured remote Range access to visible logical chunks in sharded multiscale stores.
Create a library
A library is an immutable registry of codecs. Create it once at module scope and reuse it across requests and warm Lambda invocations.
import { createImageLibrary } from 'purejsimage'
import { jpegCodec } from 'purejsimage/codecs/jpeg'
import { pngCodec } from 'purejsimage/codecs/png'
import { webpCodec } from 'purejsimage/codecs/webp'
export const images = createImageLibrary([
jpegCodec,
pngCodec,
webpCodec,
])
console.log(images.formats())
// ['jpeg', 'png', 'webp']For common web images, import allWebCodecs from purejsimage/codecs/web to register JPEG, PNG, WebP, and AVIF together. TIFF stays explicit to keep that aggregate focused. For tools that need every stable implementation, import allCodecs from purejsimage/codecs/all. Experimental HEIF/HEIC is deliberately excluded from both aggregates and requires the direct purejsimage/codecs/experimental/heic import described in the API reference.
Open an image
open() detects the registered format from content, not the filename. Node accepts a path, Buffer, Uint8Array, ArrayBuffer, Blob, or custom ImageSource. Browsers accept File/Blob, Uint8Array, ArrayBuffer, or a custom source.
const image = await images.open('photo.jpg')
const metadata = await image.metadata()
console.log({
width: metadata.width,
height: metadata.height,
format: metadata.format,
orientation: metadata.orientation,
})Metadata inspection avoids decoding all pixels. When called on a transformed pipeline, it reports the planned output dimensions and format.
Remote or custom sources
A custom source exposes its byte length and a range-read function. Each in-range read must return exactly min(length, size - offset) bytes as a Uint8Array, or reject if the backing read fails. Returned bytes only need to remain valid until the next read begins.
import type { ImageSource } from 'purejsimage'
const source: ImageSource = {
size: contentLength,
async read(offset, length) {
return fetchRange(offset, length)
},
}
const image = await images.open(source)File, Blob, and custom sources use up to four aligned 256 KiB cache regions. This bounds the shared cache at 1 MiB while coalescing small reads. Contract violations and rejected custom reads surface as ImageError results.
Build a pipeline
Image pipelines are immutable and lazy. Each method returns a new Image; decoding begins only at toBuffer() or toFile(). You can safely branch from a common source.
const source = (await images.open(upload)).autoOrient()
const hero = source
.resize({ width: 1600, height: 900, fit: 'cover' })
.jpeg({ quality: 82 })
const thumb = source
.resize({ width: 320, height: 320, fit: 'cover' })
.webp({ quality: 76 })
const heroBytes = await hero.toBuffer()
const thumbBytes = await thumb.toBuffer()Resize behavior
| Fit | Result | Typical use |
|---|---|---|
cover | Fills exact dimensions and center-crops overflow. Default when both dimensions are present. | Cards and hero images |
contain | Fits inside exact dimensions and pads the remaining canvas. | Logos and product images |
fill | Uses the exact dimensions without preserving aspect ratio. | Known geometric assets |
inside | Preserves aspect ratio inside the maximum dimensions. | General upload limits |
outside | Preserves aspect ratio while meeting both minimum dimensions. | Minimum-size masters |
Resize uses lanczos3 by default to suppress downscale aliasing. Choose bilinear for a faster lower-quality path or nearest to preserve hard pixel edges. Set withoutEnlargement: true when small uploads should remain at their original size.
position and background are available only with fit: 'contain', and fit options require both width and height.Transform order
Spatial operations execute in call order. A crop after a resize uses resized coordinates, and a second resize works from the first resize result. rotate() uses clockwise degrees, flip() mirrors top-to-bottom, and flop() mirrors left-to-right.
Recipes
Build a whole-slide viewer from remote SVS tiles
An Aperio SVS file is a tiled, multi-resolution TIFF. A viewer does not need to download or decode the complete slide. Open the remote file through HttpRangeSource, inspect its pyramid with openAperioSvs(), and decode only the tiles that intersect the canvas viewport.
Keep file access and tile decoding in a Web Worker. The main thread chooses a pyramid level, requests visible tile coordinates, and draws transferred ImageBitmap objects. The following worker-side core opens a slide and converts one native RGB tile into a canvas-ready bitmap.
import type { PixelBlock } from 'purejsimage'
import { openAperioSvs } from 'purejsimage/pathology'
import type { WholeSlideLevel } from 'purejsimage/pathology'
import { HttpRangeSource } from 'purejsimage/sources/http-range'
import { openTiffDocument } from 'purejsimage/tiff'
const source = await HttpRangeSource.open(slideUrl, {
blockBytes: 65_536,
maxCacheBytes: 1_048_576,
})
const document = await openTiffDocument(source, {
maxInputBytes: 6_000_000_000,
maxWidth: 250_000,
maxHeight: 250_000,
maxPixels: 20_000_000_000,
maxDecodedBytes: 268_435_456,
maxSegmentCount: 1_000_000,
maxSegmentTableBytes: 33_554_432,
maxEncodedSegmentBytes: 134_217_728,
})
const slide = await openAperioSvs(document)
const copyRgbBlock = (
target: Uint8ClampedArray,
targetWidth: number,
targetHeight: number,
block: PixelBlock,
): void => {
if (block.format !== 'rgb8') throw new Error(`Expected RGB8, received ${block.format}`)
if (block.x < 0 || block.y < 0 || block.x + block.width > targetWidth || block.y + block.height > targetHeight) {
throw new Error('Decoded pixels lie outside the requested tile')
}
for (let row = 0; row < block.height; row += 1) {
let input = row * block.stride
let output = ((block.y + row) * targetWidth + block.x) * 4
for (let column = 0; column < block.width; column += 1) {
target[output] = block.data[input] ?? 0
target[output + 1] = block.data[input + 1] ?? 0
target[output + 2] = block.data[input + 2] ?? 0
target[output + 3] = 255
input += 3
output += 4
}
}
}
const tileToBitmap = async (
level: WholeSlideLevel,
column: number,
row: number,
signal: AbortSignal,
): Promise<ImageBitmap> => {
if (level.tileWidth === undefined || level.tileHeight === undefined) {
throw new Error('The selected pyramid level is not tiled')
}
const width = Math.min(level.tileWidth, level.width - column * level.tileWidth)
const height = Math.min(level.tileHeight, level.height - row * level.tileHeight)
const rgba = new Uint8ClampedArray(width * height * 4)
for await (const block of level.tile(column, row, { signal })) {
try {
copyRgbBlock(rgba, width, height, block)
} finally {
block.release?.()
}
}
return createImageBitmap(new ImageData(rgba, width, height))
}The canvas side needs four policies to remain responsive:
- Choose the finest level whose
downsampleis no greater than1 / zoom. - Convert the viewport bounds to level coordinates, divide by
tileWidthandtileHeight, and request only the intersecting rows and columns. - Create one
AbortControllerper tile. Abort work as soon as its key leaves the visible set. - Keep a bounded LRU of transferred bitmaps. Draw cached coarse-level tiles first so the previous view remains visible while sharper tiles arrive, and call
bitmap.close()on eviction.
206 Partial Content and an accurate Content-Range. Cross-origin browser use must allow your origin and expose Content-Range. If the storage service cannot provide those headers, place a range-preserving endpoint on your own origin.The live whole-slide demo adds a four-request decode queue, stale-request cancellation, a 192-tile LRU, lower-resolution placeholders, pan and zoom math, and live transfer statistics. Read the complete worker implementation and canvas implementation for production-ready bounds checks and message handling.
Normalize an upload to JPEG
const output = await (await images.open(upload))
.autoOrient()
.resize({ width: 2048, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 80, background: '#ffffff' })
.toBuffer()Retain EXIF and ICC metadata
Metadata is stripped by default. Opt in to EXIF and ICC independently. Reorienting pixels also normalizes the retained EXIF orientation so downstream viewers do not rotate them a second time.
await (await images.open('camera.jpg'))
.keepExif()
.keepIcc()
.autoOrient()
.resize({ width: 1600, withoutEnlargement: true })
.jpeg({ quality: 82 })
.toFile('camera-web.jpg')JPEG, PNG, and WebP support retained EXIF and compatible ICC profiles. TIFF supports compatible ICC profiles but not retained EXIF. Unsupported combinations fail explicitly.
Convert a Windows icon to PNG
The ICO decoder selects the largest entry, then prefers useful bit depth and alpha. It decodes only that selected PNG- or DIB-backed image.
import { createImageLibrary } from 'purejsimage'
import { icoCodec } from 'purejsimage/codecs/ico'
import { pngCodec } from 'purejsimage/codecs/png'
const icons = createImageLibrary([icoCodec, pngCodec])
await (await icons.open('favicon.ico'))
.resize({ width: 64, height: 64, fit: 'contain' })
.png({ compressionLevel: 7 })
.toFile('favicon.png')Create a transparent contained PNG
await (await images.open('logo.webp'))
.resize({
width: 800,
height: 800,
fit: 'contain',
background: 'transparent',
})
.png({ compressionLevel: 7 })
.toFile('logo.png')Compose ordered transforms
Use coordinates in the image state at that point in the pipeline. Here the crop uses resized coordinates, then the cropped image is resized again and rotated clockwise onto an opaque canvas.
await (await images.open('camera.jpg'))
.autoOrient()
.resize({ width: 1600 })
.crop({ x: 200, y: 150, width: 1200, height: 900 })
.resize({ width: 600, kernel: 'lanczos3' })
.rotate(12, { background: '#ffffffff' })
.flop()
.jpeg({ quality: 80 })
.toFile('transformed.jpg')Deploy on Lambda
Keep the library at module scope. The immutable registry and small coefficient caches can be reused on warm invocations.
- Register only the codecs your endpoint accepts and produces.
- Set input limits to the smallest values the application accepts.
- Prefer downscaling and decoder-aware crops before expensive format conversion.
- Measure absolute peak RSS under realistic concurrent workloads, not only allocation deltas.
const image = await images.open(body, {
limits: {
maxInputBytes: 15 * 1024 * 1024,
maxWidth: 12_000,
maxHeight: 12_000,
maxPixels: 60_000_000,
maxDecodedBytes: 240_000_000,
},
})Pass { temporaryFiles: true } as the second argument to createImageLibrary() to opt into file storage under os.tmpdir(). This uses about one padded decoded frame of storage. It can substantially reduce process RSS, but the memory path was 20–28% faster across the measured orientation and arbitrary-rotation cases. The measured 4000 × 3000 RGBA orientation used 147.69 MiB peak RSS and 654.72 ms with memory, compared with 90.90 MiB and 820.48 ms with a file.
A tmpfs still consumes host memory outside process RSS. PureJsImage tests file creation, writing, reading, and truncation before consuming input rows. Failed setup or later file writes, including ENOSPC, move the spool to memory and preserve output. The temporary directory is removed on success or failure. An error that prevents recovery of bytes already written to the file becomes a structured ImageError.
Handle failures
PureJsImage uses five stable error categories. Check the code to decide whether to reject an upload, tighten limits, or report a capability boundary.
import { ImageError } from 'purejsimage'
try {
return await pipeline.toBuffer()
} catch (error) {
if (error instanceof ImageError) {
console.warn(error.code, error.message)
}
throw error
}See the error reference for the meaning of each category.