jpegtran-js
Lossless JPEG rotation as a WebAssembly module, built from the Independent JPEG Group's libjpeg,
release 10 (jpegsrc.v10.tar.gz) together with its transupp.c transform engine.
The quantized DCT coefficients are rearranged — never dequantized, never requantized — so a rotation
causes no quality loss and no generation loss. It is the WebAssembly equivalent of
jpegtran -rotate 90 -perfect -copy all -outfile output.jpg input.jpg
except that everything happens in memory (via jpeg_mem_src / jpeg_mem_dest): no filesystem, no
subprocess, no native binary.
I will maintain and update this package, so don't worry because it's new.
Installation
npm install @chimok/jpegtran-js
Layout
src/jpegtran_wrapper.c | the exported C API: transform, perfect query, error reporting |
src/index.ts | the wrapper source: createRotator() and the transform/copy/status constants |
src/cli.ts | the CLI source |
dist/index.js | compiled from src/index.ts, the entry point of the package |
dist/index.d.ts | compiled from src/index.ts, the type declarations of the package |
dist/cli.js | compiled from src/cli.ts, the jpegtran-js binary |
dist/jpegtran.mjs + dist/jpegtran.wasm | the WebAssembly build, produced by build.sh |
tsconfig.json | type checking (tsc, no emit) |
tsconfig.build.json | emits dist/index.js, dist/index.d.ts and dist/cli.js |
build.sh | builds dist/jpegtran.mjs + dist/jpegtran.wasm from the libjpeg sources |
test.ts | differential test against the system jpegtran/djpeg |
test/simple.ts | small smoke test on the bundled sample image |
test/crop.ts | differential test against jpegtran -crop |
test/fixtures/ | the fixture corpus (see Test) |
LICENSE | the IJG and MIT licences, plus the notes this project has to add |
jpeg/ | throwaway build copy of the libjpeg sources (regenerated by build.sh) |
Sources live in src/, everything the build emits lives in dist/. The whole of dist/ is in
.gitignore — nothing generated is committed.
Build
Requires Emscripten. build.sh looks for emcc in the PATH and otherwise sources an emsdk_env.sh
from the EMSDK environment variable, from /usr/share/emscripten or from ~/emsdk, so a toolchain
that is only on the PATH of login shells works as well.
./build.sh
The libjpeg sources are taken from, in this order:
- a build tree that is already there under
jpeg/,
- the source tree given in
JPEG_SRC,
- the official release archive, which is downloaded from
https://www.ijg.org/files/jpegsr10.zip into jpegsr10.zip and unpacked under jpeg/.
JPEG_SRC=/path/to/jpeg-10 ./build.sh
JPEG_URL=https://example.org/jpegsr10.zip ./build.sh
The archive is kept in the project directory, so a second build does not download again. Two quirks of
the IJG archive are handled automatically: it is packed without Unix permissions and with CRLF line
endings, which would otherwise break the shebang of configure.
The result is tiny: ~176 KB wasm plus a 13 KB JS loader.
JavaScript and types
dist/index.js, dist/index.d.ts and dist/cli.js are compiled from the TypeScript sources:
pnpm run build:js
pnpm run build runs both steps (./build.sh and build:js). The compiled files are not committed,
so a fresh clone runs pnpm run build:js before the tests; CI builds first and publishes exactly
what it tested.
The sources are written in "native" TypeScript: only erasable syntax is used
(erasableSyntaxOnly enforces it), so Node runs src/index.ts, src/cli.ts and the tests directly
by stripping the types — no loader, no bundler. Type checking is pnpm run typecheck.
Only the compiled output is published. files in package.json lists dist and
src/jpegtran_wrapper.c; no .ts file is part of the package.
The type declarations in dist/index.d.ts are generated from src/index.ts, so the documented API
and the types cannot drift apart.
Usage
import { createRotator, TRANSFORM, COPY } from "@chimok/jpegtran-js";
import { readFileSync, writeFileSync } from "node:fs";
const rotator = await createRotator();
console.log(rotator.version());
const input = readFileSync("photo.jpg");
const { data, perfect } = rotator.rotate(input, 90, { copy: COPY.all });
writeFileSync("photo-rotated.jpg", data);
console.log(perfect);
Lossless cropping, on its own or combined with a transform:
const cropped = rotator.crop(input, { width: 200, height: 150, x: 100, y: 50 });
writeFileSync("photo-cropped.jpg", cropped.data);
console.log(cropped.width, cropped.height);
rotator.crop(input, "200x150+100+50");
rotator.crop(input, "200x150-10-10");
rotator.crop(input, "200x150f+100+50");
rotator.transformDetailed(input, { transform: TRANSFORM.rotate90, crop: "200x150+0+0", copy: COPY.all });
Lower-level entry points:
rotator.perfectPossible(input, TRANSFORM.rotate90);
rotator.transform(input, { transform: TRANSFORM.rotate270, copy: COPY.all, perfect: false });
rotator.transformDetailed(input, { transform: TRANSFORM.none, crop: "100x100" });
rotate() mirrors the previous jpegtran behaviour of jpegtran exactly: it reports whether the
transform was perfect (-perfect would have accepted it) and performs the transform either way,
which is what jpegtran does when -perfect is omitted.
Transform numbers: none, rotate90, rotate180, rotate270, flipHorizontal, flipVertical,
transpose, transverse.
Errors are thrown as TransformError with a status of STATUS.notJpeg, STATUS.notPerfect,
STATUS.badCrop, STATUS.library or STATUS.badArgument, plus the IJG message and its message code.
About cropping
Like jpegtran -crop, cropping is lossless, but it is not exact unless you ask for it: JPEG
coefficients are stored in iMCU blocks, so IJG silently moves the upper left corner of the region to
an iMCU boundary and grows the region so that it still covers what you asked for. The lower right
corner stays where it was. Add the f suffix (or use JCROP_FORCE semantics via the string spec) to
get exactly the requested dimensions instead. -perfect looks at the whole image, not at the crop
region, exactly like jpegtran.
Test
A quick smoke test on the bundled sample image:
node test/simple.ts
It rotates test/fixtures/lenna.jpg, writes the results to test/out/ and checks that the output is
a valid JPEG, that the JFIF segment survives, that 90 + 270 degrees decodes back to the original
pixels, and that the bytes match jpegtran -rotate 90 -copy all exactly. The two comparisons need
djpeg/jpegtran and are skipped when those are not installed.
The thorough tests compare against the system jpegtran on the fixture corpus in test/fixtures:
node test.ts [fixture-dir]
node test/crop.ts [fixture-dir]
The corpus is deliberately small and covers every shape the tests rely on: 4:2:0 / 4:2:2 / 4:4:4,
baseline and progressive, MCU-aligned and not, with and without Exif, plus one file that only pretends
to be a JPEG. Another corpus can be passed as the first argument or through JPEGTRAN_FIXTURES.
Current result on that corpus:
- byte-identical output to
jpegtran for every fixture and every rotation (21/21),
- the "perfect" predicate agrees with
jpegtran -perfect in every case,
- Exif and the other APPn markers survive unchanged,
- non-JPEG input is rejected with
STATUS.notJpeg,
jpegtran -crop and crop extension match in all 164 crop checks.
Notes
-copy all must be requested explicitly. The IJG default is JCOPYOPT_COMMENTS, i.e. only COM
markers — Exif would be dropped. COPY.all maps to JCOPYOPT_ALL.
- The output uses the standard Annex K Huffman tables, like
jpegtran without -optimize, so it can
be slightly larger than the input. That is expected and still lossless.
- Exif orientation is a tag inside a copied marker; the transform does not rewrite it (same as
jpegtran). Rotating a photo that already carries an orientation tag leaves that tag untouched.
- 8-bit samples only (
BITS_IN_JSAMPLE == 8). 12/16-bit JPEG would need a second build.
Licence and provenance
This software is based in part on the work of the Independent JPEG Group.
The IJG licence is reproduced verbatim in LICENSE, together with the notes the licence
requires this project to add (which files are new and which IJG code is used unmodified).
- libjpeg release 10, 25-Jan-2026 — Independent JPEG Group (Thomas G. Lane, Guido Vollbeding),
including its
transupp.c transform engine. Used unmodified; the libjpeg sources are not vendored
in this repository.
- Emscripten 6.0.9 (system package), no SIMD/nasm — the IJG code is pure C, which is why it builds
for WebAssembly without changes.
test/fixtures/lenna.jpg is the classical Lenna test image, included only as a sample photograph
for the smoke test. It is not covered by the licence above.
The package carries both licences at the same time, which is what the SPDX expression IJG AND MIT
says — it is AND, not OR, because the package contains both kinds of file:
dist/jpegtran.wasm and dist/jpegtran.mjs are compiled from IJG libjpeg and its transupp.c and
stay under the IJG licence.
- everything else — the TypeScript sources, the compiled JavaScript, the CLI, the tests and the build
script — is under the MIT licence.
Redistribution is unproblematic: the IJG licence is a free software licence and, per the
FSF licence list, compatible with the
GNU GPL. Its condition (2) only asks that the acknowledgement above ("This software is based in part
on the work of the Independent JPEG Group.") ends up in your documentation if you ship the compiled
module, and that the names of the IJG authors are not used to endorse your product. The same terms
apply to libjpeg-turbo and mozjpeg, so this is not something specific to this package.