Skip to main content

Map a Crop Box Across Exif Orientations in Node.js

Updated 6 min readMarkdown

A portrait can look upright in a phone preview while its stored pixel grid is sideways. If a crop was recorded against the stored grid, drawing it over an oriented preview can move the box to the wrong part of the face. This Node.js example maps the crop into the upright image before any preview scaling.

1. Identify the coordinate space

Read width, height and the Exif Orientation value from the stored image metadata. The input crop must use that same grid. If a decoder has already applied orientation and the crop was drawn on that decoded image, keep both in the decoded grid. Applying the metadata again would move the crop twice.

Use integer pixel edges. The left and top edges are inclusive and the right and bottom edges are exclusive. A box covering the entire stored image runs from zero to width and height. This convention makes a reflected edge width minus x, while a reflected pixel index would use width minus one minus x.

2. Map two opposite corners

Save the function as orient-crop.mjs. Each of the eight Exif values swaps or reflects the coordinate axes. These transforms keep rectangles aligned to the axes, so two opposite corners determine the new bounds.

export function orientCrop({ width, height, orientation, box }) {
  if (![width, height].every((n) => Number.isSafeInteger(n) && n > 0)) {
    throw new RangeError("Expected positive integer image dimensions");
  }
  if (!Number.isInteger(orientation) || orientation < 1 || orientation > 8) {
    throw new RangeError("Expected an Exif orientation from 1 through 8");
  }

  const { left, top, right, bottom } = box ?? {};
  if (![left, top, right, bottom].every(Number.isSafeInteger) ||
      left < 0 || top < 0 || right > width || bottom > height ||
      left >= right || top >= bottom) {
    throw new RangeError("Expected a nonempty crop inside the stored raster");
  }

  const transforms = [
    (x, y) => [x, y],
    (x, y) => [width - x, y],
    (x, y) => [width - x, height - y],
    (x, y) => [x, height - y],
    (x, y) => [y, x],
    (x, y) => [height - y, x],
    (x, y) => [height - y, width - x],
    (x, y) => [y, width - x],
  ];
  const transform = transforms[orientation - 1];
  const [ax, ay] = transform(left, top);
  const [bx, by] = transform(right, bottom);
  return {
    width: orientation >= 5 ? height : width,
    height: orientation >= 5 ? width : height,
    box: {
      left: Math.min(ax, bx),
      top: Math.min(ay, by),
      right: Math.max(ax, bx),
      bottom: Math.max(ay, by),
    },
  };
}

The function rejects empty or out-of-bounds rectangles. Orientation values 5 through 8 swap the upright width and height. A missing metadata tag can be passed as 1 when the caller uses the Exif default. Keep unexpected values visible as input errors.

3. Run an asymmetric example

Save this as demo.mjs and run node demo.mjs from the same directory. The fictional crop is offset from every edge of the stored raster. Orientation 6 rotates the image clockwise by a quarter turn.

import { orientCrop } from "./orient-crop.mjs";

const crop = orientCrop({
  width: 4000,
  height: 3000,
  orientation: 6,
  box: { left: 500, top: 400, right: 2200, bottom: 2000 },
});
console.log(JSON.stringify(crop, null, 2));
{
  "width": 3000,
  "height": 4000,
  "box": {
    "left": 1000,
    "top": 500,
    "right": 2600,
    "bottom": 2200
  }
}

The original box is 1700 pixels wide and 1600 pixels high. The transformed box is 1600 pixels wide and 1700 pixels high. Its top edge is the original left edge at 500, and its left edge is 3000 minus 2000.

4. Test rotations and reflections

Use a 4 by 3 grid with a distinct letter at every position. The expected upright grids are written explicitly, so the test does not use the transform to generate its own expected layout. It checks every nonempty rectangle across all eight orientations.

import assert from "node:assert/strict";
import { orientCrop } from "./orient-crop.mjs";

const raster = ["ABCD", "EFGH", "IJKL"];
const displayed = [
  ["ABCD", "EFGH", "IJKL"],
  ["DCBA", "HGFE", "LKJI"],
  ["LKJI", "HGFE", "DCBA"],
  ["IJKL", "EFGH", "ABCD"],
  ["AEI", "BFJ", "CGK", "DHL"],
  ["IEA", "JFB", "KGC", "LHD"],
  ["LHD", "KGC", "JFB", "IEA"],
  ["DHL", "CGK", "BFJ", "AEI"],
];

function selected(rows, { left, top, right, bottom }) {
  return rows.slice(top, bottom)
    .flatMap((row) => [...row.slice(left, right)])
    .sort();
}

let rectangles = 0;
for (const [index, rows] of displayed.entries()) {
  for (let top = 0; top < 3; top++) {
    for (let bottom = top + 1; bottom <= 3; bottom++) {
      for (let left = 0; left < 4; left++) {
        for (let right = left + 1; right <= 4; right++) {
          const box = { left, top, right, bottom };
          const result = orientCrop({
            width: 4, height: 3, orientation: index + 1, box,
          });
          assert.deepEqual(
            [result.width, result.height, selected(rows, result.box)],
            [rows[0].length, rows.length, selected(raster, box)],
          );
          rectangles++;
        }
      }
    }
  }
}

const input = {
  width: 4000, height: 3000, orientation: 6,
  box: { left: 500, top: 400, right: 2200, bottom: 2000 },
};
assert.deepEqual(orientCrop(input), {
  width: 3000, height: 4000,
  box: { left: 1000, top: 500, right: 2600, bottom: 2200 },
});
assert.throws(() => orientCrop({ ...input, width: 0 }), /dimensions/);
assert.throws(() => orientCrop({ ...input, orientation: 9 }), /orientation/);
assert.throws(() => orientCrop({ ...input, orientation: "6" }), /orientation/);
assert.throws(() => orientCrop({ ...input, box: null }), /crop/);
assert.throws(() => orientCrop({ ...input, box: { ...input.box, bottom: 3001 } }), /crop/);
assert.throws(() => orientCrop({ ...input, box: { ...input.box, left: 2200 } }), /crop/);
console.log(`${rectangles} rectangle checks and 7 example or invalid-input checks passed`);

Run node test.mjs. The comparison checks that the crop still contains the same labeled pixels after the transform. The asymmetric example adds a check at photo-sized dimensions.

5. Place the crop over the preview

Keep the upright image dimensions alongside the mapped crop. If the entire image is displayed at half size, the box starts at 500 by 250 preview pixels. If the preview adds a margin or clips the image, include that offset before drawing the overlay.

6. Keep the calculation close to decoding

Document whether your decoder applies orientation and whether it preserves the tag. Pass the crop and dimensions from the same stage. For an integration check, use a non-square fixture with visible corner labels and compare the preview with the exported crop.

The example was checked in Node.js 24.20.0 using synthetic coordinates and labeled grids. JPEG decoding, metadata extraction, actual photo cropping and visual checks were outside the test.

AI assistance was used in drafting and editing this article.

Source workflow. How to Turn a Selfie into an ID Photo

Technical reference. CIPA Exif orientation tag 274

About the author

  • The PixMind team shares practical AI image and video workflows. PixMind brings text-to-image, reference-based creation, editing, and video tools into one workspace. Website: https://www.pixmind.io/

No comments yet. Be the first to comment.

More from this blog