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.
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.
npm install @rayabelcode/expo-image-orientation-normalizer
cd ios && pod installRequires:
- Expo SDK 50 or newer
- React Native 0.73 or newer
- iOS 16.4+ deployment target
- Android
minSdkVersion24+
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)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';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 |
- 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.
maxLongEdgemust be > 0;qualityis 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, andcontent://(Android only — copied to app cache first).
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.
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.
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).
MIT — see LICENSE.