npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@chimok/lossless-rotate

v0.7.0

Published

Make JPEGs upright or crop them: read and write the EXIF orientation tag, rotate and crop JPEGs losslessly in the DCT domain, and handle the orientation of AVIF.

Downloads

550

Readme

@chimok/lossless-rotate

CI Socket Node License

Lossless rotate & crop for JPEGs — in the DCT domain, so not a single pixel is re-encoded.

A note on the name. The package is called @chimok/lossless-rotate, but it no longer only rotates: since 0.7.0 it also crops losslessly. Sorry for the name.

This is a pure TypeScript version of jpegtran. There is also a WASM version, which is better tested: @chimok/jpegtran-js. For JPEG XL there i've build a wasm version: @chimok/jxltran-js.

The AVIF irot/imir properties are defined by the HEIF standard, ISO/IEC 23008-12, and listed for AVIF in the AVIF specification (§9.1.2). Not every browser applies them yet, but they are the lossless way to orient an AVIF, so they are supported here for the future.

lossless-rotate reads and writes the Exif orientation tag, rotates JPEG images by 90°/180°/270° and the diagonal mirrors, and crops them losslessly: the quantized DCT coefficients are rearranged or selected, never dequantized and never requantized. No quality loss, no generation loss, no native code.

The same reasoning covers AVIF: it stores its orientation not in a tag but in the irot/imir properties of its primary item. AV1 has no lossless rotation, so orientation() reads and writes those properties (rebuilding the property set and repairing the iloc extents, image data untouched) and autoRotate refuses an AVIF as well. See AVIF.

  • Pure TypeScript, published as plain ESM plus type declarations, compiled from source at pack time.
  • Zero runtime dependencies. No sharp, no gm, no jpegtran, no C/C++ addon, no compiler step.
  • Lossless by construction. The transform operates on the entropy-coded coefficients; decoding a transformed file and decoding a rotated copy of the original agree exactly (verified against jpegtran).
  • Lossless crop (crop / cropBuffer): whole coefficient blocks, optionally in the same pass as a rotation.
  • Baseline and progressive input. Progressive files are decoded across all scans and written out as a sequential JPEG — still coefficient-exact.
  • AVIF orientation is written as the irot/imir item properties; the property set is rebuilt and the iloc extents shifted to match, so the image data survives byte for byte.
  • The embedded Exif thumbnail can be rotated along with the main image (rotateThumbnail), so both end up the same way up — see the thumbnail section.
  • A CLI (lossless-rotate info|rotate|set|thumbnail) for quick checks without writing code.

Requirements

  • Node.js ≥ 24.
  • ESM. From CommonJS, require("@chimok/lossless-rotate") works on Node ≥ 22.12 via require(esm).

Install

npm install @chimok/lossless-rotate

Usage

Read or set the orientation tag

Setting the tag only rewrites ten bytes of the Exif IFD — the image data is untouched.

import { orientation } from "@chimok/lossless-rotate";

await orientation("IMG_0001.jpg");        // -> 6 (0 when there is no tag)
await orientation("IMG_0001.jpg", 1);     // metadata only, still lossless

Rotate a JPEG losslessly

import { autoRotate } from "@chimok/lossless-rotate";

const result = await autoRotate("IMG_0001.jpg", "rotated.jpg");
// result.orientation -> 6        (the value that was stored in the source)
// result.transform   -> "rotate90"
// result.perfect     -> true
// result.copied      -> false    (true when the image was already correctly oriented)
// result.warnings    -> []

Without a destination you get the bytes back instead of writing a file:

import { autoRotate } from "@chimok/lossless-rotate";
const { bytes } = await autoRotate("IMG_0001.jpg");

Or work purely on buffers:

import { autoRotateBuffer, setOrientation, canTransform } from "@chimok/lossless-rotate";

const { result, bytes } = autoRotateBuffer(source);
const tagged = setOrientation(source, 6);   // insert/patch the tag, no rotation
canTransform(source);                        // false instead of throwing for non-JPEGs

Crop a JPEG

The DCT blocks of the region are copied as they are, so the remaining pixels are the originals. The origin has to sit on the MCU grid (16×16 for 4:2:0); otherwise it is rounded down like jpegtran and warnings says so (onUnaligned: "error" refuses).

import { crop, cropBuffer } from "@chimok/lossless-rotate";

await crop("IMG_0001.jpg", { x: 160, y: 120, width: 640, height: 480 }, "crop.jpg");
const { bytes } = cropBuffer(source, { x: 0, y: 0, width: 800, height: 600 });

// Same pass as a rotation; the rectangle counts in the rotated image.
await autoRotate("IMG_0001.jpg", "out.jpg", { crop: { x: 0, y: 0, width: 1200, height: 900 } });

AVIF

An AVIF still image is one ISOBMFF item. Its orientation is not a tag in a metadata block but the irot/imir property pair of that item, which maps onto the eight Exif orientations. orientation() detects the container from the ftyp box and works the same way:

import { orientation, autoRotate, canTransform, setOrientation } from "@chimok/lossless-rotate";

await orientation("photo.avif");        // -> 6, from the irot/imir properties (0 when there are none)
await orientation("photo.avif", 1);     // writes irot 0 explicitly, the image data is untouched
await orientation("plain.avif", 6);     // adds an irot property (and repairs iloc) when there is none

await autoRotate("photo.avif", "rotated.avif");  // throws E_UNSUPPORTED
canTransform(avifBytes);                          // false
setOrientation(avifBytes, 6);                     // Buffer in, Buffer out

The details that matter:

  • No rotation. AV1 has no coefficient-domain transform, so autoRotate/autoRotateBuffer raise E_UNSUPPORTED and point at the properties; every renderer (Chrome, Firefox, libavif-based tooling) honours them, so the image still appears correctly oriented.
  • The property set is the minimal one libavif writes: a pure mirror (2/4) carries only imir, a rotation or the identity carries irot (with angle 0 for orientation 1), and a transpose/transverse (5/7) carries both. For all eight orientations the output is byte-identical to avifenc --irot/--imir.
  • A write is a rewrite, not a patch. Adding a property grows ipco/ipma, which moves meta and everything behind it, usually mdat, so the iloc extents are shifted by exactly the difference. The image data is copied byte for byte — verified by decoding the result with avifdec.
  • Every iloc flavour is handled. The item id may be 16 or 32 bits (iloc version 0/1/2, with the construction_method field when present) and the extents may be absolute or relative to a base offset; the rewrite keeps the extent pointing at the same mdat bytes in each case.
  • Only the primary item counts. The transform must be associated with the item pitm names; an irot that belongs to another item is ignored, and a primary item without an ipma entry gets one.
  • Reading tolerates the real world: pitm decides the primary item, ipma may use 16-bit or wide associations and a version-1 item id, and the properties may sit in any order. A structurally broken box tree is reported as E_INVALID_AVIF, and an AVIF image sequence (avis, track based, with no item properties) as E_UNSUPPORTED_FORMAT.

Options

await autoRotate(source, destination, {
  onImperfect: "warn",    // "warn" (default) or "error"
  orientationInOutput: 1, // value written to the result, default 1 (correctly oriented)
  keepMetadata: true,     // carry APPn/COM segments over, default true
  rotateThumbnail: false, // rotate the embedded Exif thumbnail too, default false
  crop: { x: 0, y: 0, width: 1200, height: 900 }, // optional, counted after the rotation
});

crop(source, region, [destination], [options]) takes onUnaligned, keepMetadata and orientationInOutput (default undefined: a crop leaves the tag alone).

The embedded Exif thumbnail

A camera usually stores a second, small JPEG in the Exif block (IFD1 tags 0x0201/0x0202) — the thumbnail the file manager shows. It has its own copy of the pixels, so rotating only the main image leaves it lying the wrong way up.

rotateThumbnail: true applies the same transform to that thumbnail as well:

const { result } = await autoRotate("IMG_0001.jpg", "rotated.jpg", { rotateThumbnail: true });
// result.thumbnailRotated -> true   (false when there was no thumbnail, or nothing to rotate)
  • Still lossless. The thumbnail is a JPEG too, so it is transformed with the same coefficient-exact code — no sharp, no re-encode from pixels.
  • Opt-in. The block has to be re-fixed-up when the thumbnail's length changes, so the behaviour is never on by default. Without the option the thumbnail is carried over byte-for-byte.
  • Errors instead of a silent stale thumbnail. Because the point of the option is that both images end up the same way up, a thumbnail that cannot be transformed is not quietly skipped: an unsupported thumbnail (arithmetic coding, 12-bit, two components) raises E_UNSUPPORTED, a broken one (not a JPEG, a structurally broken Exif block, SubIFDs) raises E_INVALID_EXIF. Warnings from the thumbnail are prefixed with the embedded Exif thumbnail: , so they cannot be confused with the main image's.
  • The offset fixup. Transforming the thumbnail changes its length, so everything that sits behind it in the Exif block moves. 0x0202 (length) is rewritten in place, and every 4-byte offset field that points behind the thumbnail is shifted by exactly the difference — which is the usual layout (Make/Model first, thumbnail last) needing no fixup at all. One caveat: a MakerNote that stores absolute TIFF offsets internally is not fixed up, and a block that declares SubIFDs is refused rather than mis-shifted.

Command line

The package ships a small CLI for looking at and fixing files without writing any code:

npx lossless-rotate info photo.jpg                 # container, orientation, size, thumbnail
npx lossless-rotate rotate photo.jpg --thumbnail   # rotate losslessly, thumbnail included
npx lossless-rotate rotate photo.jpg --rotate 90   # turn it 90 degrees clockwise
npx lossless-rotate rotate photo.jpg --crop 640x480+160+120  # keep that rectangle (after the turn)
npx lossless-rotate rotate *.jpg -o rotated/       # several files into a directory
npx lossless-rotate set photo.jpg 6                # write the orientation only, no re-encode
npx lossless-rotate set photo.avif 6               # ... also for AVIF
npx lossless-rotate thumbnail photo.jpg            # extract the embedded Exif thumbnail

| Command | What it does | | --- | --- | | info <file> | Prints the format, orientation, size, sampling, the transform that would be applied, and (for JPEG) whether an Exif thumbnail is embedded and can be rotated. --json prints the same as JSON. | | rotate <file...> | Rotates JPEGs losslessly. --thumbnail rotates the embedded thumbnail too, --crop WxH+X+Y keeps only that rectangle, --strict refuses imperfect transforms and unaligned crops, --drop-metadata drops APPn/COM. Refuses AVIF (E_UNSUPPORTED). | | set <file> <orientation> | Writes the orientation only — the Exif tag for JPEG, the irot/imir property for AVIF; the image data is copied untouched (a minimal Exif block is added when there is none). | | thumbnail <file> | Extracts the embedded Exif thumbnail as a JPEG. Exits 1 when there is none. |

rotate takes no angle. Rotating an image is not "turn it by N degrees": how far to turn is read from the orientation tag the file already carries (90, 180 or 270, or a mirror). info prints it, rotate applies it. There is nothing to work out by hand:

$ lossless-rotate info photo.jpg
orientation  6 (RightTop)
transform    rotate90

--rotate <deg> is for the other case — turning by a fixed amount. It is counted from the way the image is displayed, so the stored tag is applied first and cleared afterwards:

lossless-rotate rotate photo.jpg --rotate 90      # 90 degrees clockwise from what you see
lossless-rotate rotate photo.jpg --rotate 270     # the other way (90 counter-clockwise)

A tag of 6 already means "turn 90 clockwise", so --rotate 90 on such a file becomes rotate180 in one pass — one lossless transform, not two, so the partial edge blocks only move once. If the tag and the requested angle cancel out (tag 3 with --rotate 180), the file is copied instead of re-encoded. A file with no tag at all gets its pixels turned and no tag invented for it.

--crop WxH+X+Y is applied after --rotate and counts from the displayed image; without --rotate it is a plain crop.

Results are written into tmp/ (git-ignored, safe to delete) unless -o/--output says otherwise, so trying something out never modifies the input and never leaves anything behind that would show up in git status. Errors are printed as <code>: <message> and set the exit status to 1.

Lossless, but only perfect transforms are faithful

A lossless transform rearranges whole 8×8 coefficient blocks and the MCU grid. That only lines up with the visible image when the image dimensions are a whole number of MCUs along the axes a transform touches:

| Transform | Perfect when | | --- | --- | | flip horizontal, rotate 270° | width is a multiple of 8 × maxH | | flip vertical, rotate 90° | height is a multiple of 8 × maxV | | rotate 180°, transverse | both | | transpose | always |

For anything else the padding of the partial edge MCUs moves into the visible area, which shifts the image. lossless-rotate behaves like jpegtran: it performs the transform anyway and reports it.

const { result } = autoRotateBuffer(source);
if (!result.perfect) {
  console.warn(result.warnings[0]);
  // "... its dimensions are not multiples of the MCU size (16x16 pixels). The padding of the
  //  partial edge iMCUs moves into the visible area, which shifts the image ..."
}

Pass { onImperfect: "error" } to refuse such files with E_UNSUPPORTED instead. To get a faithful rotation for them, crop or pad to a multiple of the MCU size first, or re-encode the image.

For a perfect transform the decoded result is byte-identical to jpegtran's output; the package's test suite asserts this across image sizes, sampling factors (4:2:0, 4:2:2, 4:4:4), and all seven transforms.

A crop has the mirrored constraint — its origin has to be on the MCU grid, not its size — see Crop a JPEG.

What the package does with a file

  • Exif is preserved. APPn and COM segments are carried over unchanged; the orientation tag in the result is set to 1 (configurable). keepMetadata: false drops them.
  • The embedded Exif thumbnail is carried over unchanged unless rotateThumbnail: true asks for it to be rotated with the same primitive. A crop does not touch the thumbnail — it cannot be scaled down losslessly — and a warning says that the thumbnail still shows the uncropped image.
  • Progressive in, sequential out. Progressive input is decoded scan by scan and re-encoded as a baseline JPEG, with the source's quantization tables. A warning says so. Progressive output is out of scope.
  • Restart markers are dropped (always legal); a warning says so.
  • An already correctly oriented image is copied byte-for-byte, never re-encoded; so is a crop of the whole image.
  • Images without Exif are not touched by autoRotateBuffer; it reports orientation 0 and copies.

Errors

Every failure is an OrientError with a stable code:

| Code | Meaning | | --- | --- | | E_CANT_OPEN | The file could not be opened or read. | | E_UNSUPPORTED_FORMAT | Not a still image of a supported kind: neither a JPEG (FF D8) nor an AVIF (ftyp with an AVIF brand) — or an AVIF image sequence (avis), which is track based and carries no item properties. | | E_NOT_JPEG | A buffer-level JPEG entry point got something without an SOI marker. | | E_PREMATURE_END | The file ends inside a segment or inside the entropy data. | | E_INVALID_EXIF | The Exif block is structurally broken, has no orientation tag to update, or (with rotateThumbnail) cannot be fixed up safely. | | E_INVALID_JPEG | The JPEG structure is broken (markers, tables, entropy data). | | E_INVALID_AVIF | The AVIF ISOBMFF structure is broken (a box runs past the end of the file, or meta/iprp/ipco/ipma is missing). | | E_INVALID_VALUE | An argument was out of range (e.g. an orientation outside 1..8, or a crop region that is empty, fractional, negative or outside the image). | | E_UNSUPPORTED | A feature this package cannot transform losslessly (e.g. arithmetic coding, onImperfect: "error", onUnaligned: "error", rotating or cropping an AVIF, or rotateThumbnail on a thumbnail it cannot transform). |

Lower-level API

The transformer and the parsing layers are exported for callers that want to build on them:

import { transformJpeg, parseJpeg, decodeCoefficients, frameGeometry, isPerfectTransform } from "@chimok/lossless-rotate";

transformJpeg(bytes, { primitive, crop }) applies one primitive (flipHorizontal, flipVertical, rotate180, rotate90, rotate270, transpose, transverse, or none) and, optionally, crops the result in the same pass. decodeCoefficients and parseJpeg expose the decoded blocks and the parsed structure. For a crop alone there is cropGeometry(frameGeometry(frame), region), which returns the block arithmetic and the region that the MCU grid allows, plus cropRegionToString for the WxH+X+Y notation.

The TIFF/Exif code sits one level below the container, so the tag can also be handled on a bare TIFF block. The ...InExifBlock variants take that block with its bounds instead of an APP1 segment:

import {
  readOrientationInExifBlock,  // (buffer, tiffOffset, end) -> value
  patchOrientationInExifBlock, // (buffer, tiffOffset, end, value) -> PatchResult
  buildExifTiff,               // (value) -> a bare TIFF block
  locateExifThumbnail,         // (buffer, tiffOffset, end) -> ExifThumbnail | null
  replaceExifThumbnail,        // (buffer, tiffOffset, end, thumbnail, data) -> the new Exif block
} from "@chimok/lossless-rotate";

AVIF-specific entry points live under @chimok/lossless-rotate/avif and are also exported from the root:

import {
  isAvif, isAvifSequence, avifLayout, avifImageSize, avifPropertyTypes,  // the ISOBMFF layer
  readAvifOrientation,
  writeAvifOrientation,                                   // rebuild meta, fix up iloc
  setAvifOrientation,                                     // Buffer in, Buffer out
} from "@chimok/lossless-rotate";

isAvifSequence is true for an AVIF image sequence (avis, track based, no item properties), which the orientation entry points reject with E_UNSUPPORTED_FORMAT. writeAvifOrientation returns the new bytes plus an AvifPatchResult (the PatchResult shape plus the irot/imir property indices, their byte offsets, and whether meta had to be rebuilt).

Development

pnpm build          # tsc -p tsconfig.build.json -> dist/
pnpm test           # node --test  (131 tests)
pnpm run typecheck  # tsc --noEmit
pnpm run lint       # biome check .
pnpm run lint:fix   # biome check --write .   (formats and sorts imports)

Tests are grouped by format: test/core/ covers the format-agnostic parts (the orientation table and the public API) and holds the shared fixture and CLI helpers, test/jpeg/ and test/avif/ cover the format-specific suites, and test/fixtures/ is the corpus both read.

Linting and formatting use Biome; the configuration is in biome.json.

The published package is built with pnpm run build (tsc -p tsconfig.build.json) into dist/: JavaScript plus .d.ts, with the relative .ts import specifiers rewritten to .js. Shipping the TypeScript sources directly is not an option, because Node refuses to strip types for files under node_modules (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING). typescript is a devDependency; consumers never install it. tsconfig.json sets erasableSyntaxOnly, so enums and parameter properties stay out even though the build would compile them - the source keeps running directly under Node when you work in the repository, without a build step.

The differential tests compare against jpegtran/djpeg and for AVIF against avifenc/avifdec and ImageMagick; the fixture generator additionally uses GraphicsMagick (gm) and cjpeg. All of them skip themselves when the tools are not on PATH, so pnpm test works everywhere. To regenerate the fixture corpus:

pnpm run fixtures  # needs gm, cjpeg, jpegtran

pnpm run verify:package packs the tarball, unpacks it into a throwaway node_modules and imports it from there, which is the only way to catch a broken exports/files/bin setup: Node refuses to strip types under node_modules, so a mistake there only shows up for consumers.

Releasing

Publishing is driven by the Release workflow (.github/workflows/release.yml). Bump the version and add a matching ## [x.y.z] section to CHANGELOG.md, then push the tag:

npm version patch   # or minor / major; commits and tags vX.Y.Z
git push --follow-tags

The workflow checks that the tag matches package.json, runs lint, type check and tests, publishes @chimok/lossless-rotate to npmjs (it needs the NPM_TOKEN secret) and creates a GitHub release from the matching CHANGELOG.md section.

The scope is the npm account: npmjs only accepts @chimok, and GitHub Packages would only accept the repository owner's scope instead, so there is no single name that could be published to both.

Roadmap

0.6.0 is the released version: JPEG rotation with the Exif thumbnail, plus AVIF orientation as the primary item's irot/imir properties. The next version adds lossless crop (see Crop a JPEG). WebP is deliberately out of scope — browsers ignore its Exif orientation tag (see DESIGN.md §10). Still open:

  1. AVIF Exif item: an AVIF can also carry its orientation in an embedded Exif item. libavif treats irot/imir as the source of truth and normalises that tag to 1, so the property path already covers virtually every file; reading the Exif item is a later refinement. See DESIGN.md §9.
  2. Lossless pixel rotation for AVIF (the AV1 coefficient domain) on top of the existing decode → transform → encode skeleton — if it turns out to be worth the complexity.

License

MIT — see LICENSE.