How To

Menggunakan React Native

Integrasi @rustors/react-native pada aplikasi React Native untuk iOS dan Android, mencakup New Architecture (TurboModules), CocoaPods, dan Expo.

Paket @rustors/react-native menyediakan bridge native dan pengetikan TypeScript untuk aplikasi React Native iOS dan Android. Ditulis secara native menggunakan Objective-C++ di iOS dan C++/JNI di Android, pustaka ini menyuguhkan kompatibilitas optimal di seluruh versi React Native tanpa overhead memori.


Instalasi

npm install @rustors/react-native
# atau yarn add @rustors/react-native / pnpm add @rustors/react-native

Pembahasan Mendalam Konfigurasi iOS

1. Implementasi Native Bebas Hambatan Toolchain

Modul native iOS ditulis sepenuhnya dalam Objective-C++ (.mm). Hal ini meniadakan kendala linker yang biasanya muncul pada modul berbasis Swift murni (seperti penanaman runtime library Swift atau keharusan membuat berkas dummy .swift pada aplikasi host). Modul ini dapat ditautkan ke proyek Objective-C maupun Swift tanpa konfigurasi tambahan.

2. Dukungan Ganda: New Architecture & Paper

react-native-rusto secara otomatis menyesuaikan dengan arsitektur proyek Anda:

  • New Architecture (TurboModule): Ketika RCT_NEW_ARCH_ENABLED=1 aktif, modul dikompilasi menggunakan antarmuka C++ TurboModule React Native untuk performa pemanggilan sinkron JSI terbaik.
  • Old Architecture (Paper): Menggunakan loop event asinkron standar RCTBridgeModule tanpa peringatan usang (deprecation).

3. Konfigurasi CocoaPods (ios/Podfile)

Tambahkan dependensi modul dan tentukan bundel model OCR pilihan Anda:

target 'YourApp' do
  # Kompatibel dengan framework dinamis maupun statis:
  use_frameworks! :linkage => :static

  # Pod bundel model (pilih satu atau lebih):
  pod 'RustO-Models-PPOCRv6-Tiny', '~> 0.3.2'  # ~6 MB (Direkomendasikan)
  # pod 'RustO-Models-PPOCRv6-Small', '~> 0.3.2' # ~30 MB
  # pod 'RustO-Models-PPOCRv5-Mobile', '~> 0.3.2' # ~28 MB
end

Kemudian jalankan instalasi CocoaPods:

cd ios && pod install

4. Konfigurasi Izin Kamera & Galeri (ios/YourApp/Info.plist)

Jika menggunakan kamera atau pemilih galeri (misalnya react-native-image-picker), deklarasikan izin yang diperlukan:

<key>NSCameraUsageDescription</key>
<string>Aplikasi ini memerlukan akses kamera untuk memindai dokumen fisik dengan RustO! OCR.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Aplikasi ini memerlukan akses galeri foto untuk memilih gambar dokumen yang akan dipindai.</string>

5. Konfigurasi Expo (Bare Workflow & Config Plugins)

Karena RustO! bergantung pada biner terkompilasi native, aplikasi tidak dapat berjalan pada Expo Go standar. Gunakan Expo Prebuild atau development build:

npx expo run:ios

Konfigurasi pada app.json:

{
  "expo": {
    "plugins": [
      [
        "expo-image-picker",
        {
          "photosPermission": "Aplikasi membutuhkan izin galeri untuk memindai dokumen struk."
        }
      ]
    ]
  }
}

Konfigurasi Android

Pada android/settings.gradle (atau android/build.gradle), pastikan repositori JitPack telah didaftarkan:

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven { url 'https://jitpack.io' }
    }
}

Pada android/app/build.gradle, tambahkan dependensi paket model yang sesuai:

dependencies {
    implementation 'com.github.byrizki.rusto-rs:rusto-models-ppocrv6-tiny:0.3.2'
}

1. Inisialisasi Mesin

Panggil initialize() satu kali saat aplikasi pertama kali dimuat:

import { initialize } from '@rustors/react-native';

// 1. Inisialisasi dengan model bawaan PP-OCRv6 Tiny
await initialize({ preset: 'ppv6' });

// 2. Atau inisialisasi dengan berkas model kustom:
await initialize({
  preset: 'ppv6',
  models: {
    detection: 'custom_det.rten',
    recognition: 'custom_rec.rten',
    dictionary: 'custom_dict.txt',
  },
});

2. Sumber Masukan Gambar di iOS & Android

detectText mendukung beragam representasi sumber gambar:

import { detectText } from '@rustors/react-native';

// 1. File URI (file://, atau Android content://)
const r1 = await detectText({ uri: 'file:///var/mobile/Containers/Data/.../receipt.jpg' });

// 2. Data Gambar Terenkode Base64
const r2 = await detectText({ base64: base64DataString });

// 3. Buffer Byte Mentah (Uint8Array)
const r3 = await detectText({ bytes: rawUint8Array });

!TIP Di iOS, meneruskan URI file:// lokal yang diperoleh langsung dari image picker atau cache kamera menghindari proses penyalinan string base64 berukuran besar melintasi jembatan JavaScript, sehingga menghemat alokasi memori secara signifikan.


3. Mode Keluaran & Opsi Deteksi

import { detectText, TextResult } from '@rustors/react-native';

// Mode 1: Deteksi Baris Teks (Default) -> mengembalikan TextResult[]
const lines: TextResult[] = await detectText(
  { uri: imageUri },
  { output: 'lines', textScore: 0.5 }
);

// Mode 2: Deteksi Tingkat Kata -> mengembalikan TextResult[]
const words: TextResult[] = await detectText(
  { uri: imageUri },
  { output: 'words', wordXThreshold: 0.4 }
);

// Mode 3: Rekonstruksi Tata Letak Spasial -> mengembalikan string
const spatialText: string = await detectText(
  { uri: imageUri },
  { output: 'spatial', lineYThreshold: 0.5, wordXThreshold: 0.4 }
);

4. Contoh Komponen Pemindai Dokumen di React Native

Berikut adalah contoh komponen siap pakai dengan pemilihan foto, tampilan daftar baris terdeteksi, dan rekonstruksi spasial dokumen:

import React, { useEffect, useState } from 'react';
import {
  ActivityIndicator,
  Image,
  SafeAreaView,
  ScrollView,
  StyleSheet,
  Text,
  TouchableOpacity,
  View,
} from 'react-native';
import { launchImageLibrary } from 'react-native-image-picker';
import { detectText, initialize, TextResult } from '@rustors/react-native';

export default function DocumentScannerScreen() {
  const [imageUri, setImageUri] = useState<string | null>(null);
  const [lines, setLines] = useState<TextResult[]>([]);
  const [spatialText, setSpatialText] = useState<string>('');
  const [loading, setLoading] = useState<boolean>(false);
  const [mode, setMode] = useState<'lines' | 'spatial'>('lines');

  useEffect(() => {
    initialize({ preset: 'ppv6' }).catch((err) =>
      console.error('Inisialisasi RustO Gagal:', err)
    );
  }, []);

  const handlePickImage = async () => {
    const result = await launchImageLibrary({ mediaType: 'photo', quality: 0.9 });
    if (result.assets && result.assets[0]?.uri) {
      const uri = result.assets[0].uri;
      setImageUri(uri);
      processOcr(uri);
    }
  };

  const processOcr = async (uri: string) => {
    setLoading(true);
    try {
      if (mode === 'lines') {
        const results = await detectText(
          { uri },
          {
            output: 'lines',
            maxSideLen: 1600,
            postprocess: { useDilation: true },
          }
        );
        setLines(results as TextResult[]);
        setSpatialText('');
      } else {
        const formatted = await detectText(
          { uri },
          {
            output: 'spatial',
            lineYThreshold: 0.5,
            wordXThreshold: 0.4,
          }
        );
        setSpatialText(formatted as string);
        setLines([]);
      }
    } catch (error) {
      console.error('Kesalahan OCR:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <SafeAreaView style={styles.container}>
      <View style={styles.header}>
        <TouchableOpacity style={styles.btn} onPress={handlePickImage}>
          <Text style={styles.btnText}>Pilih Dokumen</Text>
        </TouchableOpacity>

        <TouchableOpacity
          style={[styles.btn, styles.toggleBtn]}
          onPress={() => {
            const nextMode = mode === 'lines' ? 'spatial' : 'lines';
            setMode(nextMode);
            if (imageUri) processOcr(imageUri);
          }}
        >
          <Text style={styles.btnText}>Mode: {mode.toUpperCase()}</Text>
        </TouchableOpacity>
      </View>

      {loading && <ActivityIndicator size="large" color="#f97316" style={{ marginVertical: 12 }} />}

      <ScrollView style={styles.content}>
        {imageUri && (
          <View style={styles.imageWrapper}>
            <Image source={{ uri: imageUri }} style={styles.previewImage} resizeMode="contain" />
          </View>
        )}

        <View style={styles.resultContainer}>
          {mode === 'spatial' ? (
            <Text style={styles.spatialCode}>{spatialText || 'Belum ada teks spasial.'}</Text>
          ) : (
            lines.map((item, idx) => (
              <View key={idx} style={styles.lineItem}>
                <Text style={styles.lineText}>{item.text}</Text>
                <Text style={styles.lineScore}>({(item.score * 100).toFixed(0)}%)</Text>
              </View>
            ))
          )}
        </View>
      </ScrollView>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#09090b' },
  header: { flexDirection: 'row', padding: 12, gap: 8 },
  btn: { flex: 1, backgroundColor: '#f97316', padding: 12, borderRadius: 8, alignItems: 'center' },
  toggleBtn: { backgroundColor: '#27272a' },
  btnText: { color: '#ffffff', fontWeight: 'bold' },
  content: { flex: 1, padding: 12 },
  imageWrapper: {
    width: '100%',
    height: 240,
    backgroundColor: '#18181b',
    borderRadius: 8,
    marginBottom: 12,
  },
  previewImage: { width: '100%', height: '100%' },
  resultContainer: { padding: 12, backgroundColor: '#18181b', borderRadius: 8 },
  lineItem: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    paddingVertical: 4,
    borderBottomWidth: 1,
    borderBottomColor: '#27272a',
  },
  lineText: { color: '#fafafa', fontSize: 14, flex: 1 },
  lineScore: { color: '#a1a1aa', fontSize: 12, marginLeft: 8 },
  spatialCode: { color: '#38bdf8', fontFamily: 'monospace', fontSize: 13, lineHeight: 18 },
});