Skip to content

rayabelcode/expo-image-orientation-normalizer

Repository files navigation

@rayabelcode/expo-image-orientation-normalizer

npm version license

Native ingress normalization for gallery-picked images on React Native (Expo).

Decodes the source image through the platform's native frameworks (PhotoKit + ImageIO on iOS, BitmapFactory + ExifInterface + Matrix on Android), applies orientation to pixels, and writes a fresh JPEG with EXIF Orientation = 1. Downstream consumers — image manipulation libraries, crop UIs, upload services — see consistent display-oriented pixels without having to read or branch on EXIF.

Why this exists

iOS HEIC and EXIF-tagged JPEGs from gallery pickers don't reach your downstream pipeline with consistent pixel orientation. The picker returns a file:// URI to a file whose stored pixels may be in sensor orientation while the EXIF Orientation tag instructs "rotate for display." Various downstream tools (image processors, crop UIs, upload code) handle this inconsistently — and the inconsistency is per-file, not per-app. The result: some uploads end up sideways while others don't.

JS-layer heuristics can't fix this reliably because EXIF tags and stored dimensions can disagree per-file. This module bypasses the issue by normalizing at the native ingress boundary: the gallery picker hands you a URI, you pass it to normalizePickedImage, and the result is a JPEG with display-oriented pixels and Orientation = 1. Everything downstream stops caring about EXIF.

Installation

npm install @rayabelcode/expo-image-orientation-normalizer
cd ios && pod install

Requires:

  • Expo SDK 50 or newer
  • React Native 0.73 or newer
  • iOS 16.4+ deployment target
  • Android minSdkVersion 24+

Quick start

import * as ImagePicker from 'expo-image-picker';
import { normalizePickedImage } from '@rayabelcode/expo-image-orientation-normalizer';

const result = await ImagePicker.launchImageLibraryAsync({
  mediaTypes: ['images'],
  quality: 1.0,
  exif: true,
});
if (result.canceled || !result.assets[0]) return;
const asset = result.assets[0];

const normalized = await normalizePickedImage({
  uri: asset.uri,
  assetId: asset.assetId ?? undefined, // enables the PhotoKit path on iOS
  mimeType: asset.mimeType ?? undefined,
  maxLongEdge: 2000,
  quality: 0.9,
});

// normalized.uri    →  file:// to a fresh JPEG with display-oriented pixels + Orientation=1
// normalized.width  →  post-rotation pixel width
// normalized.height →  post-rotation pixel height
// normalized.source →  which native path ran (see below)

API

function normalizePickedImage(options: NormalizePickedImageOptions): Promise<NormalizedImage>;

interface NormalizePickedImageOptions {
  uri: string; // file:// URI from the picker (or content:// on Android)
  assetId?: string; // PHAsset localIdentifier — enables PhotoKit on iOS
  mimeType?: string; // best-effort hint
  maxLongEdge: number; // long-edge cap (Android downsamples at decode for OOM safety)
  quality: number; // 0..1 JPEG quality
}

interface NormalizedImage {
  uri: string; // file:// to the output JPEG (app cache directory)
  width: number; // post-rotation pixel width
  height: number; // post-rotation pixel height
  orientation: 1; // always 1
  source: NormalizationSource;
}

type NormalizationSource = 'photo-asset' | 'imageio' | 'exifinterface' | 'fallback';

Native paths

source When Stale-EXIF-resistant?
photo-asset iOS, assetId provided — PhotoKit PHImageManager.requestImage renders the photo-library canonical image, then UIGraphicsImageRenderer.draw bakes orientation to a .up CGImage Yes — renders what the user sees in Photos.app, regardless of file EXIF
imageio iOS fallback when assetId is absent or PhotoKit fails — CGImageSourceCreateThumbnailAtIndex with kCGImageSourceCreateThumbnailWithTransform: true No — metadata-faithful; trusts the file's EXIF tag
exifinterface Android — BitmapFactory (sample-size pre-decode), androidx.exifinterface.media.ExifInterface reads the tag, Matrix.postRotate / postScale applies all 8 EXIF values No — metadata-faithful; trusts the file's EXIF tag

Contract

  • Always writes a new file. Never returns the input URI.
  • Output is JPEG with EXIF Orientation = 1 (or absent).
  • Throws on hard failure. Callers MUST surface the error and cancel — never proceed with the un-normalized input URI.
  • maxLongEdge must be > 0; quality is clamped to [0, 1].
  • Off main thread. Decode/encode runs on a background queue via Expo Modules' AsyncFunction.
  • URI schemes accepted: file://, raw filesystem paths, and content:// (Android only — copied to app cache first).

Known limitations — stale EXIF

The imageio and exifinterface paths trust the file's EXIF Orientation tag. They cannot detect a stale tag — where pixels are already display-oriented but the EXIF tag still says "rotate." Stale-EXIF inputs will come out over-rotated on these paths.

  • iOS without an assetId (raw file URI only): metadata-faithful only.
  • Android: metadata-faithful only — no equivalent of PhotoKit's display-source render.

If stale EXIF is common in your image corpus, options are (a) add Coil or Glide on Android for a display-source render path, or (b) add a user-facing rotate button as an explicit override.

Permissions

PhotoKit (iOS) requires NSPhotoLibraryUsageDescription in Info.plist. With Expo, add to app.json:

{
  "expo": {
    "ios": {
      "infoPlist": {
        "NSPhotoLibraryUsageDescription": "We need access to your photo library to import images."
      }
    }
  }
}

Android does not require additional permissions beyond what expo-image-picker (or your picker of choice) already declares.

Native frameworks used

iOS: Photos (PHAsset, PHImageManager), ImageIO (CGImageSource, CGImageDestination), UIKit (UIGraphicsImageRenderer), UniformTypeIdentifiers (UTType.jpeg). Deployment target iOS 16.4+.

Android: android.graphics.BitmapFactory, Bitmap, Matrix; androidx.exifinterface.media.ExifInterface (1.4.x).

License

MIT — see LICENSE.

About

Native EXIF image orientation normalizer for React Native (Expo Modules).

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors