# @rayabelcode/expo-image-orientation-normalizer

[![npm version](https://img.shields.io/npm/v/@rayabelcode/expo-image-orientation-normalizer.svg)](https://www.npmjs.com/package/@rayabelcode/expo-image-orientation-normalizer)
[![license](https://img.shields.io/npm/l/@rayabelcode/expo-image-orientation-normalizer.svg)](./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

```bash
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

```ts
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

```ts
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`:

```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](./LICENSE).
