react-native-receipt-scanner
v1.0.0
Published
Receipt document scanner for React Native. Wraps ML Kit (Android) and VisionKit + Vision (iOS) with a unified API for scanning, interactive perspective-crop, JPEG compression, EXIF extraction, and on-device OCR.
Maintainers
Readme
react-native-receipt-scanner

Receipt document scanner for React Native. Wraps ML Kit (Android) and VisionKit + Vision (iOS) with a unified API for scanning, interactive perspective-crop, JPEG compression, EXIF extraction, and on-device OCR.
Built exclusively for the React Native New Architecture — no legacy bridge, no base64 blobs.
Why this library?
| | react-native-receipt-scanner | @dariyd/react-native-document-scanner | react-native-document-scanner-plugin |
| ------------------------------ | :------------------------------: | :-----------------------------------: | :---------------------------------------: |
| New Architecture (TurboModule) | ✅ iOS + Android | ✅ iOS only | ⚠️ JS only — native still uses old bridge |
| Camera scan | ✅ | ✅ | ✅ |
| Gallery import + crop editor | ✅ Interactive perspective-crop | ✅ | ✅ |
| On-device OCR | ✅ Multilingual (BCP 47) | ❌ | ❌ |
| EXIF metadata | ✅ | ✅ | ❌ |
| GPS EXIF | ✅ opt-in | ✅ opt-in | ❌ |
| Result format | file:// URI only | URI or Base64 | URI or Base64 |
| Min iOS | 16.0 | 13.0 | 13.0 |
| Min Android | API 24 (7.0) | API 21 (5.0) | API 21 (5.0) |
| Google Play Services required | ✅ Android only | ✅ Android only | ✅ Android only |
What makes this library different
On-device OCR. Neither competitor extracts text from scanned documents. This library runs the Vision framework (iOS) and ML Kit Text Recognition (Android) on-device — no network call, no external API key. The ocrText field on each image is the plain-text content of that page.
Caller-chosen OCR languages. Pass ordered BCP 47 hints via ocrLanguages (default ["ko-KR", "en-US"]). iOS forwards the ordered list to Vision; Android resolves it to one ML Kit script family — Latin, Korean, Japanese, Chinese, or Devanagari — with Korean bundled and the rest delivered through Play services. getOcrCapabilities() reports what the current device can serve. See Multilingual OCR.
Interactive perspective-crop in gallery mode. When source: "gallery" is used on iOS, VNDetectRectangles automatically locates the document corners. A drag-handle overlay (RNCropEditorViewController) lets the user correct the crop before the image is processed. The result is a perspective-corrected JPEG — not a raw photo.
TurboModule on both platforms. The module calls go through JSI on iOS and the codegen-generated NativeReceiptScannerSpec on Android. There is no asynchronous bridge serialization for the call path.
file:// URIs only. Images are written to the app cache directory and returned as file:// paths. Base64 is never used — it doubles memory usage and is slower to transfer across the bridge.
Requirements
| Platform | Minimum version | Notes | | ------------ | -------------------- | ---------------------------------------------------------------------------- | | iOS | 16.0 | VNDocumentCameraViewController requires iOS 13+; Korean OCR requires iOS 16+ | | Android | API 24 (Android 7.0) | ML Kit Document Scanner requires Google Play Services | | React Native | 0.77+ | New Architecture must be enabled |
Installation
yarn add react-native-receipt-scanneriOS — CocoaPods
cd ios && pod installAndroid
No extra steps. The ML Kit Document Scanner dependency is included automatically via Gradle.
Note: The ML Kit Document Scanner downloads its model from Google Play Services on first use. This requires an active internet connection on first launch and does not work on AOSP devices without Google Play Services (e.g., most Android emulators without Play Store).
Setup
iOS — Info.plist
The camera scanner (source: "camera") requires camera access. The gallery picker (source: "gallery") uses the system photo picker (PHPickerViewController) and does not require a photo library permission. Add the following key to ios/<YourApp>/Info.plist:
<key>NSCameraUsageDescription</key>
<string>Used to scan receipt documents.</string>Note:
includeGpsExif: truedoes not requireNSLocationWhenInUseUsageDescription. The library only copies the GPS dictionary already embedded in the source image's EXIF — there is noCLLocationManagercall.
Android — AndroidManifest.xml
No extra entries are required. The ML Kit scanner handles its own camera permission prompt internally, and the gallery flow uses the system photo picker — neither requires a declared storage or media permission.
Usage
Basic camera scan
import { scan } from "react-native-receipt-scanner";
const result = await scan();
if (result.status === "success") {
for (const image of result.images) {
console.log(image.uri); // file:///...receipt_123.jpg
console.log(image.ocrText); // extracted text (when ocr: true)
}
}Gallery import with perspective-crop
With source: "gallery", the user selects a photo and is presented with an interactive crop editor. The document corners are detected automatically; the user can adjust them before confirming.
const result = await scan({ source: "gallery" });Localizing the crop editor
The crop editor shows an instruction plus two buttons: a cancel button (left) and a confirm button (right).
On iOS, add the following keys to each locale's Localizable.strings in your Xcode project:
/* ios/en.lproj/Localizable.strings */
"RNReceiptScanner_cropInstruction" = "Drag the corners to frame the document";
"RNReceiptScanner_cancelButton" = "Cancel";
"RNReceiptScanner_confirmButton" = "Use Photo";
/* ios/ko.lproj/Localizable.strings */
"RNReceiptScanner_cropInstruction" = "문서의 네 모서리를 맞춰 주세요";
"RNReceiptScanner_cancelButton" = "취소";
"RNReceiptScanner_confirmButton" = "사진 사용";On Android, override the same resource names in the host app's strings.xml files.
The camera scanner uses the platform scanner UI and is not affected.
Multi-page scan
const result = await scan({ source: "camera", maxPages: 5 });
if (result.status === "success") {
console.log(`Scanned ${result.images.length} pages`);
}Long receipts — merging OCR across pages
A receipt too long for one frame is captured across several overlapping pages, or saved as a run of screenshots and picked from the gallery.
mergeOcrPages assembles their OCR text into one ordered string and removes the text duplicated where two captures overlap.
const result = await scan({ maxPages: 6, mergeOcrPages: true });
const merged = result.mergedOcr;
if (merged && !merged.isComplete) {
// A seam that could not be proven; both pages' text is still in merged.text.
console.log(`unproven seams: ${merged.unmatchedBoundaryIndexes.join(", ")}`);
}Capture each page so it repeats at least a couple of lines from the end of the previous one — the merge proves a seam from repeated text, so with no overlap there is nothing to match. There is no upper limit: the deepest repeated run wins, so a generous overlap is strictly safer than a thin one. With no provable overlap the boundary is reported and every line is kept.
The page JPEGs are never combined into one tall image. Android caps the processed long edge at 3,072 px and iOS Vision's minimum text height is a fraction of the whole image height, so a taller composite recovers less text, not more. See ADR-008.
Two behaviours worth knowing:
- An unproven seam emits both pages' lines in full and reports its boundary index — the merge does not drop text to make a result look clean.
- One case does lose content, and cannot be detected. If a receipt prints the same block of lines twice and the page split falls between the two copies, the text is identical to what a real overlap produces, so the later copy is removed as a duplicate and
isCompletestill reportstrue. Overlapping your captures generously makes the real overlap deeper than any repeated block, which is the practical defence. isCompleteonly covers the pages that came back. Both iOS paths can silently drop a page that fails to process, and the JS layer cannot see that — so it means "everything returned joined up", not "the whole receipt is here". Android fails the whole scan instead, so it does not have this gap.
OCR — extract text from the document
OCR runs on-device and is enabled by default. Set ocr: false to skip it (faster, smaller result):
// With OCR (default)
const result = await scan({ ocr: true });
// Without OCR
const result = await scan({ ocr: false });Multilingual OCR and capability discovery
Choose native OCR with ordered BCP 47 hints, and inspect the active platform without opening scanner UI:
import { getOcrCapabilities, scan } from "react-native-receipt-scanner";
const capabilities = await getOcrCapabilities();
const result = await scan({
ocrLanguages: ["es-ES", "en-US"],
ocrFloor: false,
});ocrLanguages defaults to ["ko-KR", "en-US"]. These hints select native OCR only. They do not infer a receipt country or parse receipt / invoice fields.
On iOS, capabilities contains the ordered Vision identifiers currently supported by the active request. On Android, it contains script model states ("ready" or "download-required"); querying it never starts a download. The web fallback returns { platform: "web", defaultLanguages: ["ko-KR", "en-US"], supported: false }.
Non-Korean and non-English scripts exposed by a provider are provider-supported but uncalibrated until fixture coverage exists.
EXIF metadata
const result = await scan({ includeExif: true });
if (result.status === "success") {
const { exif } = result.images[0];
console.log(exif?.orientation); // always 1 — pixels are pre-rotated; do not re-rotate
console.log(exif?.dateTimeOriginal); // "2024:12:25 10:30:00"
console.log(exif?.make); // "Apple"
console.log(exif?.model); // "iPhone 15 Pro"
console.log(exif?.software); // iOS camera: OS version ("26.4.2"); editors / generators: tool name; usually empty on Android camera and screenshots
}Note: Output JPEG pixels are always written orientation-normalized, so
exif.orientationis always1. Callers must not re-rotate.
iOS metadata limitation:
includeRawExifcontrols the returnedexif.rawfield; it does not sanitize the output JPEG. WithincludeExif: true, gallery JPEGs preserve the source EXIF and TIFF dictionaries, while GPS copying remains controlled byincludeGpsExif. Review or remove file metadata before sharing images when your application requires a strict metadata allowlist.
Note: On the gallery path EXIF is read from the original source image. On the camera path (VNDocumentCameraViewController on iOS, GmsDocumentScanner on Android) the source EXIF is stripped during the scan, so the library synthesizes
make/model/dateTimeOriginalfrom the device —softwareandgpsare absent unless the source carried them.
Cancelled state
When the user dismisses the scanner or picker without scanning, status is "cancelled" and images is an empty array:
const result = await scan();
if (result.status === "cancelled") {
console.log("User cancelled");
}Auto-rotate — handling sideways captures
A receipt photo that comes in rotated 90° / 180° / 270° (typically from a gallery import where the user or an editor rotated the image) is detected during OCR and the output JPEG is rotated upright before being returned. width / height reflect the post-rotation dimensions; exif.orientation stays 1 because the rotation is baked into the pixels.
// Default: rotation detected → output is upright
const result = await scan({ source: "gallery" });
console.log(result.images[0].width, result.images[0].height);
// → e.g. 1176 × 3530 (upright) even if the source was 3530 × 1176
// Disable to keep the original pixel orientation
const result = await scan({ source: "gallery", autoRotate: false });The detector runs only when ocr: true (OCR is the rotation signal). Portrait inputs pay near-zero cost; landscape inputs trigger a few extra fast OCR probes (~300–450 ms total).
Rejected state — non-receipt images
When ocr: true (default), the library applies a conservative OCR floor (12 characters / 2 lines) and treats captures that fail the threshold as rejected. Use this to prompt the user when they shoot a wall, the floor, or a blank page instead of a receipt:
const result = await scan();
if (result.status === "rejected") {
// Every image failed the OCR floor — likely not a receipt
alert("영수증 텍스트를 인식하지 못했습니다. 다시 촬영해 주세요.");
console.log(result.rejectedImages[0]?.ocrQuality);
// → { textLength: 3, lineCount: 1 }
}
// Tighten or relax:
await scan({ ocrFloor: { minLines: 3 } }); // require store + date + total
await scan({ ocrFloor: false }); // disable the floor entirelyIn multi-image mode, partial rejects are returned via rejectedImages alongside status: "success":
const result = await scan({ source: "gallery", maxPages: 5 });
if (result.status === "success") {
console.log(`accepted: ${result.images.length}`);
console.log(`rejected: ${result.rejectedImages.length}`);
}API reference
scan(options?): Promise<ScanReceiptResult>
| Option | Type | Default | Description |
| ------------------- | ----------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| source | 'camera' \| 'gallery' | 'camera' | Open document camera or system image picker |
| maxPages | number | 1 | Maximum pages per scan session |
| quality | number (0.0–1.0) | 0.82 | JPEG compression quality after crop |
| includeExif | boolean | true | Attach EXIF metadata to each result image |
| includeGpsExif | boolean | false | Include GPS coordinates in EXIF (off by default — privacy risk) |
| ocr | boolean | true | Run on-device text recognition and return ocrText |
| ocrLanguages | readonly string[] | ["ko-KR", "en-US"] | Ordered BCP 47 hints that select native OCR. Empty arrays or entries reject while OCR is enabled. These hints do not infer a receipt country or parse receipt / invoice fields. |
| cropAutoConfirm | boolean | false | iOS gallery only: skip the crop editor when document detection confidence is ≥ 0.85 and apply the detected corners automatically |
| ocrFloor | OcrFloor \| false | conservative default (12 chars / 2 lines) | Reject blank / non-text captures (e.g. landscape photo of a wall). Pass false to disable. Only applies when ocr: true. |
| autoRotate | boolean | true | Detect 90° / 180° / 270° content rotation via OCR confidence and rotate the output JPEG to upright. Only applies when ocr: true. |
| includeRawExif | boolean | false | Include the full raw EXIF / TIFF / GPS dictionary on exif.raw. Off by default to keep IPC payloads small. GPS keys excluded when includeGpsExif: false. |
| minimumTextHeight | number (0.0–1.0) | 0 | iOS only: minimum text height as a fraction of image height for Vision OCR (0 = platform default ≈ 1/32). Lower it to recover small receipt line items; Android (ML Kit) has no equivalent and ignores it. Only applies when ocr: true. |
| ocrGeometry | boolean | false | Attach per-line text boxes to ocrLines, in output-image pixels. Enable it to draw text-region overlays. Off by default because a long receipt can carry hundreds of lines. Only applies when ocr: true. |
| mergeOcrPages | boolean | false | Assemble the pages' OCR text into one ordered string on mergedOcr, removing text duplicated where captures overlap. For a receipt too long for one frame. Requires ocr: true and an integer maxPages >= 2; page JPEGs are never combined. |
Result types
type ScanReceiptResult = {
status: "success" | "cancelled" | "rejected";
images: ReceiptImage[]; // always an array — empty when none
rejectedImages: ReceiptImage[]; // always an array — empty when none
mergedOcr?: MergedOcrResult; // with mergeOcrPages: true, unless the scan was cancelled
};
type MergedOcrResult = {
text: string; // pages joined in capture order, proven overlap removed once
isComplete: boolean; // covers only the pages that came back — see "Long receipts" above
pageUris: string[]; // native capture order; the index space for both arrays below
unmatchedBoundaryIndexes: number[]; // i = the seam between pageUris[i] and pageUris[i + 1]
rejectedPageIndexes: number[]; // no usable text, or rejected by the OCR floor
};
type ReceiptImage = {
uri: string; // file:// path — never base64
width: number;
height: number;
fileName: string;
mimeType: "image/jpeg";
fileSize: number;
imageOrigin: ImageOrigin; // always present; classifies how the source image was produced
ocrText?: string; // present when options.ocr === true
ocrQuality?: OcrQuality; // present when options.ocr === true
ocrLines?: OcrLine[]; // present when options.ocrGeometry === true
exif?: ReceiptExif; // present when options.includeExif === true
};
type OcrLine = {
text: string; // this line's recognized text
frame: { x: number; y: number; width: number; height: number }; // output-image pixels, top-left origin
confidence?: number; // 0–1, when the platform reports one
};
type ImageOrigin = "camera" | "screenshot" | "download" | "unknown";
type OcrFloor = {
minTextLength?: number; // default 12
minLines?: number; // default 2
minConfidence?: number; // default 0 — iOS only
};
type OcrQuality = {
textLength: number; // trimmed character count
lineCount: number; // non-empty line count
confidence?: number; // mean recognition confidence (iOS only)
};
type ReceiptExif = {
// image metadata
orientation?: number; // always 1 — pixels are written orientation-normalized; raw.Orientation preserves the source value
colorSpace?: number;
lightSource?: number;
exifVersion?: string;
// device + software
make?: string;
model?: string;
software?: string; // TIFF Software tag — iOS camera writes OS version; Samsung etc. may write firmware ID; editors/generators write their name
// timestamps
dateTime?: string;
dateTimeOriginal?: string;
dateTimeDigitized?: string;
// camera settings (numeric, normalized across platforms)
exposureTime?: number; // seconds
fNumber?: number;
iso?: number; // single value, not array
focalLength?: number; // mm
flash?: number;
whiteBalance?: number;
exposureMode?: number;
exposureProgram?: number;
meteringMode?: number;
// gps (when includeGpsExif: true)
gps?: {
latitude: number;
longitude: number;
altitude?: number;
timestamp?: string;
speed?: number;
heading?: number;
};
// raw passthrough (when includeRawExif: true)
raw?: Record<string, string | number | Array<string | number>>;
};// Pull every available EXIF tag (e.g. for migrations from @lodev09/react-native-exify)
const result = await scan({ source: "gallery", includeRawExif: true });
console.log(result.images[0].exif?.raw);
// → { Make: "samsung", Software: "F741NKSS3CZCS", Orientation: "6",
// DateTimeOriginal: "...", FNumber: "1.8", ISOSpeedRatings: "100", ... }Error codes
| Code | Trigger |
| ---------------------------------------- | ------------------------------------------------------------------------------ |
| SCAN_IN_PROGRESS | scan() called while a previous call has not yet resolved |
| NO_ACTIVITY | No foreground activity (Android) / presented view controller (iOS) |
| NOT_SUPPORTED | VNDocumentCameraViewController not supported on this device (iOS) |
| SCANNER_INIT_FAILED | ML Kit scanner failed to initialize (Android) |
| SCAN_FAILED | Unexpected activity result code (Android) |
| SCAN_RESULT_ERROR | ML Kit scan result could not be parsed (Android) |
| PROCESSING_FAILED | Image processing or OCR failed |
| CAMERA_FAILED | VNDocumentCameraViewController reported an error (iOS) |
| INVALID_OCR_LANGUAGE | OCR is enabled and a hint is empty or not a valid BCP 47 tag |
| OCR_LANGUAGE_NOT_SUPPORTED | A requested language or script is not supported by the provider |
| OCR_LANGUAGE_COMBINATION_NOT_SUPPORTED | Android receives more than one non-Latin script |
| OCR_MODEL_INSTALL_FAILED | Android cannot prepare the selected OCR model before scanner UI |
| INVALID_MERGE_OPTION | mergeOcrPages is set with ocr: false, or maxPages is not an integer >= 2 |
Architecture docs
- API contract — full type reference, platform requirements,
imageOriginrules, fraud-filter notes - Scan pipeline — internal processing flow (contributors)
- Multilingual OCR —
ocrLanguages,getOcrCapabilities(), per-platform language resolution - OCR line geometry —
ocrLinescoordinate contract - Long receipt OCR merge —
mergeOcrPages, seam-matching thresholds, completeness diagnostics - OCR 180° orientation correction — confidence-based 2-pass detection (iOS)
- Platform asymmetries — living record of every iOS/Android behavioural difference
- Phase plans: 1 JS wrapper · 2 Android · 3 iOS · 4 OCR orientation · 5 autoflip/mirror · 6 data quality · 7 line geometry · 8 multilingual OCR · 9 long receipt OCR merge
- ADRs: 001 Android ML Kit · 002 iOS gallery crop · 003 package boundaries · 004 iOS crop editor real-device fixes · 005 Android gallery strategy · 006 design audit + iOS 16 baseline · 007 v0.4.2→v0.4.3 diff · 008 long receipt merge boundary
Planned
| Feature | Notes |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| PDF export with searchable text layer | Android: GmsDocumentScannerOptions.RESULT_FORMAT_PDF already produces a multi-page PDF — cost is near-zero. iOS: compose pages with PDFKit and overlay the OCR result as an invisible text layer so the file is copy-pasteable and full-text-searchable in any PDF viewer. New ScanReceiptOptions.outputFormat: "jpeg" \| "pdf" option; result carries a pdfUri alongside images. |
Contributing
- Development workflow
- Verification checklist before opening a PR — run
yarn typecheck && yarn lint && yarn test && trunk fmt && trunk checkand confirm it is clean. - Sending a pull request
- Code of conduct
License
MIT
