@rayabelcode/expo-image-orientation-normalizer 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Ray Abel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # @rayabelcode/expo-image-orientation-normalizer
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@rayabelcode/expo-image-orientation-normalizer.svg)](https://www.npmjs.com/package/@rayabelcode/expo-image-orientation-normalizer)
4
+ [![license](https://img.shields.io/npm/l/@rayabelcode/expo-image-orientation-normalizer.svg)](./LICENSE)
5
+
6
+ Native ingress normalization for gallery-picked images on React Native (Expo).
7
+
8
+ 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.
9
+
10
+ ## Why this exists
11
+
12
+ 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.
13
+
14
+ 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.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install @rayabelcode/expo-image-orientation-normalizer
20
+ cd ios && pod install
21
+ ```
22
+
23
+ Requires:
24
+
25
+ - Expo SDK 50 or newer
26
+ - React Native 0.73 or newer
27
+ - iOS 16.4+ deployment target
28
+ - Android `minSdkVersion` 24+
29
+
30
+ ## Quick start
31
+
32
+ ```ts
33
+ import * as ImagePicker from 'expo-image-picker';
34
+ import { normalizePickedImage } from '@rayabelcode/expo-image-orientation-normalizer';
35
+
36
+ const result = await ImagePicker.launchImageLibraryAsync({
37
+ mediaTypes: ['images'],
38
+ quality: 1.0,
39
+ exif: true,
40
+ });
41
+ if (result.canceled || !result.assets[0]) return;
42
+ const asset = result.assets[0];
43
+
44
+ const normalized = await normalizePickedImage({
45
+ uri: asset.uri,
46
+ assetId: asset.assetId ?? undefined, // enables the PhotoKit path on iOS
47
+ mimeType: asset.mimeType ?? undefined,
48
+ maxLongEdge: 2000,
49
+ quality: 0.9,
50
+ });
51
+
52
+ // normalized.uri → file:// to a fresh JPEG with display-oriented pixels + Orientation=1
53
+ // normalized.width → post-rotation pixel width
54
+ // normalized.height → post-rotation pixel height
55
+ // normalized.source → which native path ran (see below)
56
+ ```
57
+
58
+ ## API
59
+
60
+ ```ts
61
+ function normalizePickedImage(options: NormalizePickedImageOptions): Promise<NormalizedImage>;
62
+
63
+ interface NormalizePickedImageOptions {
64
+ uri: string; // file:// URI from the picker (or content:// on Android)
65
+ assetId?: string; // PHAsset localIdentifier — enables PhotoKit on iOS
66
+ mimeType?: string; // best-effort hint
67
+ maxLongEdge: number; // long-edge cap (Android downsamples at decode for OOM safety)
68
+ quality: number; // 0..1 JPEG quality
69
+ }
70
+
71
+ interface NormalizedImage {
72
+ uri: string; // file:// to the output JPEG (app cache directory)
73
+ width: number; // post-rotation pixel width
74
+ height: number; // post-rotation pixel height
75
+ orientation: 1; // always 1
76
+ source: NormalizationSource;
77
+ }
78
+
79
+ type NormalizationSource = 'photo-asset' | 'imageio' | 'exifinterface' | 'fallback';
80
+ ```
81
+
82
+ ## Native paths
83
+
84
+ | `source` | When | Stale-EXIF-resistant? |
85
+ | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
86
+ | `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 |
87
+ | `imageio` | iOS fallback when `assetId` is absent or PhotoKit fails — `CGImageSourceCreateThumbnailAtIndex` with `kCGImageSourceCreateThumbnailWithTransform: true` | No — metadata-faithful; trusts the file's EXIF tag |
88
+ | `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 |
89
+
90
+ ## Contract
91
+
92
+ - **Always writes a new file.** Never returns the input URI.
93
+ - **Output is JPEG** with EXIF Orientation = 1 (or absent).
94
+ - **Throws on hard failure.** Callers MUST surface the error and cancel — never proceed with the un-normalized input URI.
95
+ - **`maxLongEdge` must be > 0**; `quality` is clamped to `[0, 1]`.
96
+ - **Off main thread.** Decode/encode runs on a background queue via Expo Modules' `AsyncFunction`.
97
+ - **URI schemes accepted**: `file://`, raw filesystem paths, and `content://` (Android only — copied to app cache first).
98
+
99
+ ## Known limitations — stale EXIF
100
+
101
+ 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.
102
+
103
+ - **iOS without an `assetId`** (raw file URI only): metadata-faithful only.
104
+ - **Android**: metadata-faithful only — no equivalent of PhotoKit's display-source render.
105
+
106
+ 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.
107
+
108
+ ## Permissions
109
+
110
+ PhotoKit (iOS) requires `NSPhotoLibraryUsageDescription` in `Info.plist`. With Expo, add to `app.json`:
111
+
112
+ ```json
113
+ {
114
+ "expo": {
115
+ "ios": {
116
+ "infoPlist": {
117
+ "NSPhotoLibraryUsageDescription": "We need access to your photo library to import images."
118
+ }
119
+ }
120
+ }
121
+ }
122
+ ```
123
+
124
+ Android does not require additional permissions beyond what `expo-image-picker` (or your picker of choice) already declares.
125
+
126
+ ## Native frameworks used
127
+
128
+ **iOS**: `Photos` (PHAsset, PHImageManager), `ImageIO` (CGImageSource, CGImageDestination), `UIKit` (UIGraphicsImageRenderer), `UniformTypeIdentifiers` (UTType.jpeg). Deployment target iOS 16.4+.
129
+
130
+ **Android**: `android.graphics.BitmapFactory`, `Bitmap`, `Matrix`; `androidx.exifinterface.media.ExifInterface` (1.4.x).
131
+
132
+ ## License
133
+
134
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,22 @@
1
+ plugins {
2
+ id 'com.android.library'
3
+ id 'expo-module-gradle-plugin'
4
+ }
5
+
6
+ group = 'expo.modules.imageorientationnormalizer'
7
+ version = '0.9.0'
8
+
9
+ android {
10
+ namespace "expo.modules.imageorientationnormalizer"
11
+ defaultConfig {
12
+ versionCode 1
13
+ versionName "0.9.0"
14
+ }
15
+ lintOptions {
16
+ abortOnError false
17
+ }
18
+ }
19
+
20
+ dependencies {
21
+ implementation "androidx.exifinterface:exifinterface:1.4.2"
22
+ }
@@ -0,0 +1,2 @@
1
+ <manifest>
2
+ </manifest>
@@ -0,0 +1,187 @@
1
+ package expo.modules.imageorientationnormalizer
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.BitmapFactory
5
+ import android.graphics.Matrix
6
+ import android.net.Uri
7
+ import androidx.exifinterface.media.ExifInterface
8
+ import expo.modules.kotlin.exception.CodedException
9
+ import expo.modules.kotlin.modules.Module
10
+ import expo.modules.kotlin.modules.ModuleDefinition
11
+ import expo.modules.kotlin.records.Field
12
+ import expo.modules.kotlin.records.Record
13
+ import java.io.File
14
+ import java.io.FileOutputStream
15
+ import java.util.UUID
16
+
17
+ class ImageOrientationNormalizerModule : Module() {
18
+ override fun definition() = ModuleDefinition {
19
+ Name("ImageOrientationNormalizer")
20
+
21
+ AsyncFunction("normalizePickedImage") { options: NormalizePickedImageOptions ->
22
+ normalize(options)
23
+ }
24
+ }
25
+
26
+ private fun normalize(options: NormalizePickedImageOptions): NormalizedImage {
27
+ validate(options)
28
+ val quality = clampedQuality(options.quality)
29
+ val maxLongEdge = options.maxLongEdge.toInt()
30
+
31
+ val inputPath = resolveLocalPath(options.uri)
32
+ val inputFile = File(inputPath)
33
+ if (!inputFile.exists()) {
34
+ throw NormalizationError("Input file does not exist: $inputPath")
35
+ }
36
+
37
+ // 1. Source dims (no pixel allocation).
38
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
39
+ BitmapFactory.decodeFile(inputPath, bounds)
40
+ if (bounds.outWidth <= 0 || bounds.outHeight <= 0) {
41
+ throw NormalizationError("Cannot decode source: $inputPath")
42
+ }
43
+
44
+ // 2. Sample-size pre-decode for OOM safety on large sources.
45
+ val sampleSize = computeSampleSize(bounds.outWidth, bounds.outHeight, maxLongEdge)
46
+ val decodeOpts = BitmapFactory.Options().apply {
47
+ inSampleSize = sampleSize
48
+ inPreferredConfig = Bitmap.Config.ARGB_8888
49
+ }
50
+ val raw = BitmapFactory.decodeFile(inputPath, decodeOpts)
51
+ ?: throw NormalizationError("Decode returned null: $inputPath")
52
+
53
+ // 3. Apply EXIF orientation. Metadata-faithful — trusts the tag.
54
+ val orientation = readExifOrientation(inputPath)
55
+ val oriented = applyOrientation(raw, orientation)
56
+ if (oriented !== raw) raw.recycle()
57
+
58
+ // 4. Long-edge scale.
59
+ val finalBitmap = applyMaxLongEdge(oriented, maxLongEdge)
60
+ if (finalBitmap !== oriented) oriented.recycle()
61
+
62
+ // 5. Write JPEG to app cache.
63
+ val cacheDir = appContext.reactContext?.cacheDir
64
+ ?: throw NormalizationError("No reactContext.cacheDir available")
65
+ val outFile = File(cacheDir, "normalized_${UUID.randomUUID()}.jpg")
66
+ FileOutputStream(outFile).use { os ->
67
+ val ok = finalBitmap.compress(Bitmap.CompressFormat.JPEG, (quality * 100).toInt(), os)
68
+ if (!ok) throw NormalizationError("Failed to encode JPEG: ${outFile.absolutePath}")
69
+ }
70
+
71
+ // 6. Clear EXIF orientation on output.
72
+ ExifInterface(outFile.absolutePath).apply {
73
+ resetOrientation()
74
+ saveAttributes()
75
+ }
76
+
77
+ val width = finalBitmap.width
78
+ val height = finalBitmap.height
79
+ finalBitmap.recycle()
80
+
81
+ return NormalizedImage().apply {
82
+ uri = "file://${outFile.absolutePath}"
83
+ this.width = width.toDouble()
84
+ this.height = height.toDouble()
85
+ this.orientation = 1
86
+ source = "exifinterface"
87
+ }
88
+ }
89
+
90
+ private fun resolveLocalPath(uri: String): String {
91
+ if (uri.startsWith("file://")) return Uri.parse(uri).path
92
+ ?: throw NormalizationError("Cannot resolve file URI: $uri")
93
+ if (uri.startsWith("/")) return uri
94
+ if (uri.startsWith("content://")) return copyContentToTemp(Uri.parse(uri))
95
+ throw NormalizationError("Unsupported URI scheme: $uri")
96
+ }
97
+
98
+ private fun copyContentToTemp(uri: Uri): String {
99
+ val resolver = appContext.reactContext?.contentResolver
100
+ ?: throw NormalizationError("No contentResolver available")
101
+ val cacheDir = appContext.reactContext?.cacheDir
102
+ ?: throw NormalizationError("No reactContext.cacheDir available")
103
+ val temp = File(cacheDir, "picker_${UUID.randomUUID()}.bin")
104
+ resolver.openInputStream(uri).use { input ->
105
+ if (input == null) throw NormalizationError("Cannot open content URI: $uri")
106
+ FileOutputStream(temp).use { output -> input.copyTo(output) }
107
+ }
108
+ return temp.absolutePath
109
+ }
110
+
111
+ private fun readExifOrientation(path: String): Int {
112
+ return try {
113
+ ExifInterface(path).getAttributeInt(
114
+ ExifInterface.TAG_ORIENTATION,
115
+ ExifInterface.ORIENTATION_NORMAL,
116
+ )
117
+ } catch (e: Throwable) {
118
+ ExifInterface.ORIENTATION_NORMAL
119
+ }
120
+ }
121
+
122
+ private fun applyOrientation(src: Bitmap, orientation: Int): Bitmap {
123
+ val matrix = Matrix()
124
+ when (orientation) {
125
+ ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
126
+ ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
127
+ ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
128
+ ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
129
+ ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
130
+ ExifInterface.ORIENTATION_TRANSPOSE -> {
131
+ matrix.postRotate(90f)
132
+ matrix.postScale(-1f, 1f)
133
+ }
134
+ ExifInterface.ORIENTATION_TRANSVERSE -> {
135
+ matrix.postRotate(-90f)
136
+ matrix.postScale(-1f, 1f)
137
+ }
138
+ else -> return src
139
+ }
140
+ return Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true)
141
+ }
142
+
143
+ private fun computeSampleSize(width: Int, height: Int, maxLongEdge: Int): Int {
144
+ // Decode at the smallest sample size where the larger edge is still ≥ 2x maxLongEdge,
145
+ // leaving the final long-edge resize a clean pass over high-quality pixels.
146
+ val longestEdge = maxOf(width, height)
147
+ var sample = 1
148
+ while (longestEdge / sample > maxLongEdge * 2) {
149
+ sample *= 2
150
+ }
151
+ return sample
152
+ }
153
+
154
+ private fun applyMaxLongEdge(src: Bitmap, maxLongEdge: Int): Bitmap {
155
+ val longest = maxOf(src.width, src.height)
156
+ if (longest <= maxLongEdge) return src
157
+ val scale = maxLongEdge.toDouble() / longest.toDouble()
158
+ val targetW = (src.width * scale).toInt().coerceAtLeast(1)
159
+ val targetH = (src.height * scale).toInt().coerceAtLeast(1)
160
+ return Bitmap.createScaledBitmap(src, targetW, targetH, true)
161
+ }
162
+
163
+ private fun validate(options: NormalizePickedImageOptions) {
164
+ if (options.uri.isEmpty()) throw NormalizationError("uri is required")
165
+ if (options.maxLongEdge <= 0.0) throw NormalizationError("maxLongEdge must be > 0")
166
+ }
167
+
168
+ private fun clampedQuality(q: Double): Double = q.coerceIn(0.0, 1.0)
169
+ }
170
+
171
+ class NormalizePickedImageOptions : Record {
172
+ @Field var uri: String = ""
173
+ @Field var assetId: String? = null
174
+ @Field var mimeType: String? = null
175
+ @Field var maxLongEdge: Double = 2000.0
176
+ @Field var quality: Double = 0.9
177
+ }
178
+
179
+ class NormalizedImage : Record {
180
+ @Field var uri: String = ""
181
+ @Field var width: Double = 0.0
182
+ @Field var height: Double = 0.0
183
+ @Field var orientation: Int = 1
184
+ @Field var source: String = "fallback"
185
+ }
186
+
187
+ class NormalizationError(message: String) : CodedException(message)
@@ -0,0 +1,16 @@
1
+ export type NormalizationSource = 'photo-asset' | 'imageio' | 'exifinterface' | 'fallback';
2
+ export interface NormalizedImage {
3
+ uri: string;
4
+ width: number;
5
+ height: number;
6
+ orientation: 1;
7
+ source: NormalizationSource;
8
+ }
9
+ export interface NormalizePickedImageOptions {
10
+ uri: string;
11
+ assetId?: string;
12
+ mimeType?: string;
13
+ maxLongEdge: number;
14
+ quality: number;
15
+ }
16
+ //# sourceMappingURL=ImageOrientationNormalizer.types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ImageOrientationNormalizer.types.d.ts","sourceRoot":"","sources":["../src/ImageOrientationNormalizer.types.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,mBAAmB,GAAG,aAAa,GAAG,SAAS,GAAG,eAAe,GAAG,UAAU,CAAC;AAE3F,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,CAAC,CAAC;IACf,MAAM,EAAE,mBAAmB,CAAC;CAC7B;AAED,MAAM,WAAW,2BAA2B;IAC1C,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;CACjB"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=ImageOrientationNormalizer.types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ImageOrientationNormalizer.types.js","sourceRoot":"","sources":["../src/ImageOrientationNormalizer.types.ts"],"names":[],"mappings":"","sourcesContent":["// Which native render path produced the output. `photo-asset` is display-source\n// (stale-EXIF-resistant via PhotoKit); `imageio` and `exifinterface` are\n// metadata-faithful — they trust the file's EXIF tag and cannot detect a lying\n// tag. `fallback` indicates the stub returned the input URI unchanged.\nexport type NormalizationSource = 'photo-asset' | 'imageio' | 'exifinterface' | 'fallback';\n\nexport interface NormalizedImage {\n uri: string;\n width: number;\n height: number;\n orientation: 1;\n source: NormalizationSource;\n}\n\nexport interface NormalizePickedImageOptions {\n uri: string;\n assetId?: string;\n mimeType?: string;\n maxLongEdge: number;\n quality: number;\n}\n"]}
@@ -0,0 +1,8 @@
1
+ import { NativeModule } from 'expo';
2
+ import type { NormalizePickedImageOptions, NormalizedImage } from './ImageOrientationNormalizer.types';
3
+ declare class ImageOrientationNormalizerModule extends NativeModule<Record<string, never>> {
4
+ normalizePickedImage(options: NormalizePickedImageOptions): Promise<NormalizedImage>;
5
+ }
6
+ declare const _default: ImageOrientationNormalizerModule;
7
+ export default _default;
8
+ //# sourceMappingURL=ImageOrientationNormalizerModule.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ImageOrientationNormalizerModule.d.ts","sourceRoot":"","sources":["../src/ImageOrientationNormalizerModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAuB,MAAM,MAAM,CAAC;AAEzD,OAAO,KAAK,EACV,2BAA2B,EAC3B,eAAe,EAChB,MAAM,oCAAoC,CAAC;AAE5C,OAAO,OAAO,gCAAiC,SAAQ,YAAY,CAAC,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;IACxF,oBAAoB,CAAC,OAAO,EAAE,2BAA2B,GAAG,OAAO,CAAC,eAAe,CAAC;CACrF;;AAED,wBAAmG"}
@@ -0,0 +1,3 @@
1
+ import { requireNativeModule } from 'expo';
2
+ export default requireNativeModule('ImageOrientationNormalizer');
3
+ //# sourceMappingURL=ImageOrientationNormalizerModule.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ImageOrientationNormalizerModule.js","sourceRoot":"","sources":["../src/ImageOrientationNormalizerModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,mBAAmB,EAAE,MAAM,MAAM,CAAC;AAWzD,eAAe,mBAAmB,CAAmC,4BAA4B,CAAC,CAAC","sourcesContent":["import { NativeModule, requireNativeModule } from 'expo';\n\nimport type {\n NormalizePickedImageOptions,\n NormalizedImage,\n} from './ImageOrientationNormalizer.types';\n\ndeclare class ImageOrientationNormalizerModule extends NativeModule<Record<string, never>> {\n normalizePickedImage(options: NormalizePickedImageOptions): Promise<NormalizedImage>;\n}\n\nexport default requireNativeModule<ImageOrientationNormalizerModule>('ImageOrientationNormalizer');\n"]}
@@ -0,0 +1,4 @@
1
+ import type { NormalizePickedImageOptions, NormalizedImage, NormalizationSource } from './ImageOrientationNormalizer.types';
2
+ export type { NormalizePickedImageOptions, NormalizedImage, NormalizationSource };
3
+ export declare function normalizePickedImage(options: NormalizePickedImageOptions): Promise<NormalizedImage>;
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EACV,2BAA2B,EAC3B,eAAe,EACf,mBAAmB,EACpB,MAAM,oCAAoC,CAAC;AAG5C,YAAY,EAAE,2BAA2B,EAAE,eAAe,EAAE,mBAAmB,EAAE,CAAC;AAElF,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,2BAA2B,GACnC,OAAO,CAAC,eAAe,CAAC,CAE1B"}
package/build/index.js ADDED
@@ -0,0 +1,15 @@
1
+ // Public entry point for the ImageOrientationNormalizer module.
2
+ //
3
+ // Normalizes a gallery-picked image to a fresh JPEG with display-oriented
4
+ // pixels and EXIF Orientation=1. Throws on hard failure — callers MUST surface
5
+ // the error and cancel; never proceed with the un-normalized input URI.
6
+ //
7
+ // Only the `photo-asset` source (PhotoKit on iOS when an assetId is provided)
8
+ // is stale-EXIF-resistant. The `imageio` and `exifinterface` paths are
9
+ // metadata-faithful — they trust the file's EXIF tag and cannot detect a
10
+ // lying tag.
11
+ import ImageOrientationNormalizerModule from './ImageOrientationNormalizerModule';
12
+ export function normalizePickedImage(options) {
13
+ return ImageOrientationNormalizerModule.normalizePickedImage(options);
14
+ }
15
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,gEAAgE;AAChE,EAAE;AACF,0EAA0E;AAC1E,+EAA+E;AAC/E,wEAAwE;AACxE,EAAE;AACF,8EAA8E;AAC9E,uEAAuE;AACvE,yEAAyE;AACzE,aAAa;AAOb,OAAO,gCAAgC,MAAM,oCAAoC,CAAC;AAIlF,MAAM,UAAU,oBAAoB,CAClC,OAAoC;IAEpC,OAAO,gCAAgC,CAAC,oBAAoB,CAAC,OAAO,CAAC,CAAC;AACxE,CAAC","sourcesContent":["// Public entry point for the ImageOrientationNormalizer module.\n//\n// Normalizes a gallery-picked image to a fresh JPEG with display-oriented\n// pixels and EXIF Orientation=1. Throws on hard failure — callers MUST surface\n// the error and cancel; never proceed with the un-normalized input URI.\n//\n// Only the `photo-asset` source (PhotoKit on iOS when an assetId is provided)\n// is stale-EXIF-resistant. The `imageio` and `exifinterface` paths are\n// metadata-faithful — they trust the file's EXIF tag and cannot detect a\n// lying tag.\n\nimport type {\n NormalizePickedImageOptions,\n NormalizedImage,\n NormalizationSource,\n} from './ImageOrientationNormalizer.types';\nimport ImageOrientationNormalizerModule from './ImageOrientationNormalizerModule';\n\nexport type { NormalizePickedImageOptions, NormalizedImage, NormalizationSource };\n\nexport function normalizePickedImage(\n options: NormalizePickedImageOptions\n): Promise<NormalizedImage> {\n return ImageOrientationNormalizerModule.normalizePickedImage(options);\n}\n"]}
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["apple", "android"],
3
+ "apple": {
4
+ "modules": ["ImageOrientationNormalizerModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["expo.modules.imageorientationnormalizer.ImageOrientationNormalizerModule"]
8
+ }
9
+ }
@@ -0,0 +1,23 @@
1
+ Pod::Spec.new do |s|
2
+ s.name = 'ImageOrientationNormalizer'
3
+ s.version = '0.9.0'
4
+ s.summary = 'Native ingress normalization for gallery-picked images on React Native / Expo.'
5
+ s.description = 'Decodes via PhotoKit and ImageIO on iOS, BitmapFactory + ExifInterface + Matrix on Android; writes display-oriented JPEG with EXIF Orientation=1.'
6
+ s.author = 'Ray Abel'
7
+ s.homepage = 'https://github.com/rayabelcode/expo-image-orientation-normalizer'
8
+ s.license = { type: 'MIT', file: '../LICENSE' }
9
+ s.platforms = {
10
+ :ios => '16.4',
11
+ :tvos => '16.4'
12
+ }
13
+ s.source = { git: 'https://github.com/rayabelcode/expo-image-orientation-normalizer.git', tag: s.version.to_s }
14
+ s.static_framework = true
15
+
16
+ s.dependency 'ExpoModulesCore'
17
+
18
+ s.pod_target_xcconfig = {
19
+ 'DEFINES_MODULE' => 'YES',
20
+ }
21
+
22
+ s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
23
+ end
@@ -0,0 +1,201 @@
1
+ import ExpoModulesCore
2
+ import ImageIO
3
+ import Photos
4
+ import UIKit
5
+ import UniformTypeIdentifiers
6
+
7
+ public class ImageOrientationNormalizerModule: Module {
8
+ public func definition() -> ModuleDefinition {
9
+ Name("ImageOrientationNormalizer")
10
+
11
+ AsyncFunction("normalizePickedImage") { (options: NormalizePickedImageOptions) -> NormalizedImage in
12
+ return try self.normalize(options)
13
+ }
14
+ }
15
+
16
+ private func normalize(_ options: NormalizePickedImageOptions) throws -> NormalizedImage {
17
+ try validate(options)
18
+ let quality = clampedQuality(options.quality)
19
+ let maxLongEdge = options.maxLongEdge
20
+
21
+ if let assetId = options.assetId, !assetId.isEmpty {
22
+ if let result = try? renderViaPhotoKit(assetId: assetId, maxLongEdge: maxLongEdge, quality: quality) {
23
+ return result
24
+ }
25
+ // PhotoKit miss (asset not found, permission denied, network unavailable) → fall through.
26
+ }
27
+
28
+ return try renderViaImageIO(uri: options.uri, maxLongEdge: maxLongEdge, quality: quality)
29
+ }
30
+
31
+ // MARK: - ImageIO path (metadata-faithful)
32
+
33
+ private func renderViaImageIO(uri: String, maxLongEdge: Double, quality: Double) throws -> NormalizedImage {
34
+ guard let url = parseURL(uri) else {
35
+ throw NormalizationError.invalidUri(uri)
36
+ }
37
+ guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
38
+ throw NormalizationError.cannotOpen(uri)
39
+ }
40
+
41
+ // kCGImageSourceCreateThumbnailWithTransform: true applies EXIF during decode.
42
+ // Metadata-faithful — does NOT detect stale tags.
43
+ let opts: [CFString: Any] = [
44
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
45
+ kCGImageSourceCreateThumbnailWithTransform: true,
46
+ kCGImageSourceThumbnailMaxPixelSize: NSNumber(value: maxLongEdge),
47
+ ]
48
+ guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, opts as CFDictionary) else {
49
+ throw NormalizationError.decodeFailed
50
+ }
51
+
52
+ return try writeJpeg(cgImage: cgImage, quality: quality, source: "imageio")
53
+ }
54
+
55
+ // MARK: - PhotoKit path (display-source — stale-EXIF-resistant)
56
+
57
+ private func renderViaPhotoKit(assetId: String, maxLongEdge: Double, quality: Double) throws -> NormalizedImage {
58
+ let fetch = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil)
59
+ guard let asset = fetch.firstObject else {
60
+ throw NormalizationError.assetNotFound(assetId)
61
+ }
62
+
63
+ let requestOpts = PHImageRequestOptions()
64
+ requestOpts.isSynchronous = true
65
+ requestOpts.deliveryMode = .highQualityFormat
66
+ requestOpts.resizeMode = .exact
67
+ requestOpts.isNetworkAccessAllowed = true
68
+
69
+ var resultImage: UIImage?
70
+ PHImageManager.default().requestImage(
71
+ for: asset,
72
+ targetSize: CGSize(width: maxLongEdge, height: maxLongEdge),
73
+ contentMode: .aspectFit,
74
+ options: requestOpts
75
+ ) { image, _ in
76
+ resultImage = image
77
+ }
78
+ guard let uiImage = resultImage else {
79
+ throw NormalizationError.photoKitFailed
80
+ }
81
+
82
+ let baked = try bakeOrientation(uiImage)
83
+ return try writeJpeg(cgImage: baked, quality: quality, source: "photo-asset")
84
+ }
85
+
86
+ // MARK: - Helpers
87
+
88
+ private func bakeOrientation(_ uiImage: UIImage) throws -> CGImage {
89
+ // UIGraphicsImageRenderer.draw applies imageOrientation, producing display-space
90
+ // pixels with .up orientation. format.scale = 1 keeps pixel dims == point dims.
91
+ if uiImage.imageOrientation == .up, uiImage.scale == 1, let cg = uiImage.cgImage {
92
+ return cg
93
+ }
94
+ let format = UIGraphicsImageRendererFormat()
95
+ format.scale = 1
96
+ let renderer = UIGraphicsImageRenderer(size: uiImage.size, format: format)
97
+ let rendered = renderer.image { _ in
98
+ uiImage.draw(at: .zero)
99
+ }
100
+ guard let cg = rendered.cgImage else {
101
+ throw NormalizationError.decodeFailed
102
+ }
103
+ return cg
104
+ }
105
+
106
+ private func writeJpeg(cgImage: CGImage, quality: Double, source: String) throws -> NormalizedImage {
107
+ let outputURL = cacheJpegURL()
108
+ guard let dest = CGImageDestinationCreateWithURL(
109
+ outputURL as CFURL,
110
+ UTType.jpeg.identifier as CFString,
111
+ 1,
112
+ nil
113
+ ) else {
114
+ throw NormalizationError.destinationFailed
115
+ }
116
+
117
+ let writeOpts: [CFString: Any] = [
118
+ kCGImageDestinationLossyCompressionQuality: quality,
119
+ kCGImagePropertyOrientation: 1,
120
+ ]
121
+ CGImageDestinationAddImage(dest, cgImage, writeOpts as CFDictionary)
122
+
123
+ guard CGImageDestinationFinalize(dest) else {
124
+ throw NormalizationError.encodeFailed
125
+ }
126
+
127
+ let result = NormalizedImage()
128
+ result.uri = outputURL.absoluteString
129
+ result.width = Double(cgImage.width)
130
+ result.height = Double(cgImage.height)
131
+ result.orientation = 1
132
+ result.source = source
133
+ return result
134
+ }
135
+
136
+ private func parseURL(_ uri: String) -> URL? {
137
+ if let u = URL(string: uri), u.scheme != nil { return u }
138
+ // Tolerate raw paths.
139
+ return URL(fileURLWithPath: uri)
140
+ }
141
+
142
+ private func cacheJpegURL() -> URL {
143
+ let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
144
+ return dir.appendingPathComponent("normalized_\(UUID().uuidString).jpg")
145
+ }
146
+
147
+ private func validate(_ options: NormalizePickedImageOptions) throws {
148
+ if options.uri.isEmpty {
149
+ throw NormalizationError.invalidOptions("uri is required")
150
+ }
151
+ if options.maxLongEdge <= 0 {
152
+ throw NormalizationError.invalidOptions("maxLongEdge must be > 0")
153
+ }
154
+ }
155
+
156
+ private func clampedQuality(_ q: Double) -> Double {
157
+ return max(0, min(1, q))
158
+ }
159
+ }
160
+
161
+ struct NormalizePickedImageOptions: Record {
162
+ @Field var uri: String = ""
163
+ @Field var assetId: String?
164
+ @Field var mimeType: String?
165
+ @Field var maxLongEdge: Double = 2000
166
+ @Field var quality: Double = 0.9
167
+ }
168
+
169
+ class NormalizedImage: Record {
170
+ @Field var uri: String = ""
171
+ @Field var width: Double = 0
172
+ @Field var height: Double = 0
173
+ @Field var orientation: Int = 1
174
+ @Field var source: String = "fallback"
175
+
176
+ required init() {}
177
+ }
178
+
179
+ enum NormalizationError: Error, LocalizedError {
180
+ case invalidUri(String)
181
+ case invalidOptions(String)
182
+ case cannotOpen(String)
183
+ case decodeFailed
184
+ case destinationFailed
185
+ case encodeFailed
186
+ case assetNotFound(String)
187
+ case photoKitFailed
188
+
189
+ var errorDescription: String? {
190
+ switch self {
191
+ case .invalidUri(let uri): return "Invalid URI: \(uri)"
192
+ case .invalidOptions(let reason): return "Invalid options: \(reason)"
193
+ case .cannotOpen(let uri): return "Cannot open image source: \(uri)"
194
+ case .decodeFailed: return "Failed to decode source image"
195
+ case .destinationFailed: return "Failed to create JPEG destination"
196
+ case .encodeFailed: return "Failed to encode JPEG output"
197
+ case .assetNotFound(let id): return "PhotoKit asset not found: \(id)"
198
+ case .photoKitFailed: return "PhotoKit returned no image"
199
+ }
200
+ }
201
+ }
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@rayabelcode/expo-image-orientation-normalizer",
3
+ "version": "0.9.0",
4
+ "description": "Native ingress normalization for gallery-picked images on React Native / Expo. Decodes via PhotoKit and ImageIO on iOS, BitmapFactory + ExifInterface + Matrix on Android; writes display-oriented JPEG with EXIF Orientation=1.",
5
+ "main": "build/index.js",
6
+ "types": "build/index.d.ts",
7
+ "sideEffects": false,
8
+ "files": [
9
+ "build",
10
+ "src",
11
+ "ios",
12
+ "android",
13
+ "expo-module.config.json",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "build": "node internal/module_scripts/build.js",
19
+ "clean": "node internal/module_scripts/clean.js",
20
+ "lint": "eslint src/",
21
+ "test": "node internal/module_scripts/test.js",
22
+ "prepare": "node internal/module_scripts/prepare.js",
23
+ "open:ios": "node internal/module_scripts/open-ios.js",
24
+ "open:android": "node internal/module_scripts/open-android.js"
25
+ },
26
+ "keywords": [
27
+ "react-native",
28
+ "expo",
29
+ "expo-modules",
30
+ "exif",
31
+ "orientation",
32
+ "image-picker",
33
+ "heic",
34
+ "jpeg",
35
+ "photokit",
36
+ "imageio",
37
+ "exifinterface",
38
+ "ios",
39
+ "android"
40
+ ],
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/rayabelcode/expo-image-orientation-normalizer.git"
44
+ },
45
+ "bugs": {
46
+ "url": "https://github.com/rayabelcode/expo-image-orientation-normalizer/issues"
47
+ },
48
+ "author": "Ray Abel (https://github.com/rayabelcode)",
49
+ "license": "MIT",
50
+ "homepage": "https://github.com/rayabelcode/expo-image-orientation-normalizer#readme",
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "dependencies": {},
55
+ "devDependencies": {
56
+ "@babel/core": "^7.26.0",
57
+ "@types/jest": "^29.2.1",
58
+ "@types/react": "~19.1.1",
59
+ "babel-preset-expo": "~55.0.8",
60
+ "eslint": "~9.39.4",
61
+ "eslint-config-universe": "^15.0.3",
62
+ "expo": "^56.0.3",
63
+ "jest": "^29.7.0",
64
+ "jest-expo": "~55.0.9",
65
+ "prettier": "^3.0.0",
66
+ "react-native": "0.82.1",
67
+ "typescript": "^5.9.2"
68
+ },
69
+ "jest": {
70
+ "preset": "jest-expo",
71
+ "roots": [
72
+ "<rootDir>/src"
73
+ ]
74
+ },
75
+ "peerDependencies": {
76
+ "expo": "*",
77
+ "react": "*",
78
+ "react-native": "*"
79
+ }
80
+ }
@@ -0,0 +1,21 @@
1
+ // Which native render path produced the output. `photo-asset` is display-source
2
+ // (stale-EXIF-resistant via PhotoKit); `imageio` and `exifinterface` are
3
+ // metadata-faithful — they trust the file's EXIF tag and cannot detect a lying
4
+ // tag. `fallback` indicates the stub returned the input URI unchanged.
5
+ export type NormalizationSource = 'photo-asset' | 'imageio' | 'exifinterface' | 'fallback';
6
+
7
+ export interface NormalizedImage {
8
+ uri: string;
9
+ width: number;
10
+ height: number;
11
+ orientation: 1;
12
+ source: NormalizationSource;
13
+ }
14
+
15
+ export interface NormalizePickedImageOptions {
16
+ uri: string;
17
+ assetId?: string;
18
+ mimeType?: string;
19
+ maxLongEdge: number;
20
+ quality: number;
21
+ }
@@ -0,0 +1,12 @@
1
+ import { NativeModule, requireNativeModule } from 'expo';
2
+
3
+ import type {
4
+ NormalizePickedImageOptions,
5
+ NormalizedImage,
6
+ } from './ImageOrientationNormalizer.types';
7
+
8
+ declare class ImageOrientationNormalizerModule extends NativeModule<Record<string, never>> {
9
+ normalizePickedImage(options: NormalizePickedImageOptions): Promise<NormalizedImage>;
10
+ }
11
+
12
+ export default requireNativeModule<ImageOrientationNormalizerModule>('ImageOrientationNormalizer');
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ // Public entry point for the ImageOrientationNormalizer module.
2
+ //
3
+ // Normalizes a gallery-picked image to a fresh JPEG with display-oriented
4
+ // pixels and EXIF Orientation=1. Throws on hard failure — callers MUST surface
5
+ // the error and cancel; never proceed with the un-normalized input URI.
6
+ //
7
+ // Only the `photo-asset` source (PhotoKit on iOS when an assetId is provided)
8
+ // is stale-EXIF-resistant. The `imageio` and `exifinterface` paths are
9
+ // metadata-faithful — they trust the file's EXIF tag and cannot detect a
10
+ // lying tag.
11
+
12
+ import type {
13
+ NormalizePickedImageOptions,
14
+ NormalizedImage,
15
+ NormalizationSource,
16
+ } from './ImageOrientationNormalizer.types';
17
+ import ImageOrientationNormalizerModule from './ImageOrientationNormalizerModule';
18
+
19
+ export type { NormalizePickedImageOptions, NormalizedImage, NormalizationSource };
20
+
21
+ export function normalizePickedImage(
22
+ options: NormalizePickedImageOptions
23
+ ): Promise<NormalizedImage> {
24
+ return ImageOrientationNormalizerModule.normalizePickedImage(options);
25
+ }