false, 'message' => 'Google Vision API key is not configured.', 'data' => self::emptyData(), ]; } try { $base64Image = base64_encode($file->get()); $url = "https://vision.googleapis.com/v1/images:annotate?key={$apiKey}"; $payload = [ 'requests' => [ [ 'image' => [ 'content' => $base64Image, ], 'features' => [ [ 'type' => 'TEXT_DETECTION', ], ], ], ], ]; $response = Http::withoutVerifying()->timeout(30)->post($url, $payload); if (!$response->successful()) { Log::error('Google Vision API error response: ' . $response->body()); return [ 'success' => false, 'message' => 'Google Vision API request failed.', 'data' => self::emptyData(), ]; } $json = $response->json(); $fullText = $json['responses'][0]['fullTextAnnotation']['text'] ?? ''; if (empty($fullText)) { Log::warning('Google Vision API did not detect any text.'); return [ 'success' => true, 'data' => self::emptyData(), ]; } $parsedData = self::parsePassportText($fullText); return [ 'success' => true, 'data' => $parsedData, ]; } catch (\Exception $e) { Log::error('Google Vision OCR exception: ' . $e->getMessage()); return [ 'success' => false, 'message' => 'An error occurred during OCR processing.', 'data' => self::emptyData(), ]; } } private static function emptyData(): array { return [ 'date_of_birth' => null, 'date_of_expiry' => null, 'date_of_issue' => null, 'given_names' => null, 'issuing_country' => null, 'nationality' => null, 'passport_number' => null, 'place_of_birth' => null, ]; } /** * Parse raw OCR text to extract passport fields. */ public static function parsePassportText(string $text): array { $data = self::emptyData(); // Split text into lines $lines = explode("\n", $text); $cleanLines = []; foreach ($lines as $line) { $line = trim($line); if ($line !== '') { $cleanLines[] = $line; } } // Try to parse MRZ first $mrzLines = self::findMrzLines($cleanLines); if ($mrzLines) { $mrzData = self::parseMrz($mrzLines[0], $mrzLines[1]); foreach ($mrzData as $key => $val) { if ($val !== null) { $data[$key] = $val; } } } // ── Passport Number fallback ───────────────────────────────────────── if (empty($data['passport_number'])) { foreach ($cleanLines as $line) { if (preg_match('/(?:passport|pass\s*no|doc\s*no|document|number|no\.?)\s*[:\-\s]\s*([A-Z0-9]{7,9})/i', $line, $matches)) { $data['passport_number'] = strtoupper($matches[1]); break; } } if (empty($data['passport_number'])) { foreach ($cleanLines as $line) { if (preg_match('/\b([A-Z][0-9]{7,8}|[A-Z0-9]{8,9})\b/', $line, $matches)) { $data['passport_number'] = strtoupper($matches[1]); break; } } } } // ── Given Names fallback ───────────────────────────────────────────── if (empty($data['given_names'])) { foreach ($cleanLines as $line) { if (preg_match('/(?:given\s*names?|first\s*names?|name|nom)\s*[:\-\s]\s*([A-Z\s]{2,})/i', $line, $matches)) { $data['given_names'] = trim(strtoupper($matches[1])); break; } } } // ── Place of Birth fallback ────────────────────────────────────────── if (empty($data['place_of_birth'])) { foreach ($cleanLines as $line) { if (preg_match('/(?:place\s*of\s*birth|lieu\s*de\s*naissance|birthplace)\s*[:\-\s]\s*([A-Z\s]{2,})/i', $line, $matches)) { $data['place_of_birth'] = trim(strtoupper($matches[1])); break; } } } // ── Dates fallback ─────────────────────────────────────────────────── if (empty($data['date_of_birth'])) { $data['date_of_birth'] = self::findDateByKeyword($cleanLines, ['birth', 'dob', 'naissance']); } if (empty($data['date_of_expiry'])) { $data['date_of_expiry'] = self::findDateByKeyword($cleanLines, ['expiry', 'expiration', 'exp', 'valable']); } if (empty($data['date_of_issue'])) { $data['date_of_issue'] = self::findDateByKeyword($cleanLines, ['issue', 'delivrance', 'issued']); } // ── Nationality fallback ───────────────────────────────────────────── if (empty($data['nationality'])) { foreach ($cleanLines as $line) { if (preg_match('/(?:nationality|nationalite)\s*[:\-\s]\s*([A-Z\s]{3,})/i', $line, $matches)) { $data['nationality'] = trim(strtoupper($matches[1])); break; } } } // ── Issuing Country fallback ───────────────────────────────────────── if (empty($data['issuing_country'])) { foreach ($cleanLines as $line) { if (preg_match('/(?:issuing\s*state|issuing\s*country|country)\s*[:\-\s]\s*([A-Z\s]{3,})/i', $line, $matches)) { $data['issuing_country'] = trim(strtoupper($matches[1])); break; } } } return $data; } /** * Look for MRZ lines in clean lines. */ private static function findMrzLines(array $lines): ?array { $candidateLines = []; foreach ($lines as $line) { $cleaned = str_replace(' ', '', $line); $cleaned = str_replace('«', '<', $cleaned); if (strlen($cleaned) >= 35 && strlen($cleaned) <= 50) { $candidateLines[] = [ 'original' => $line, 'cleaned' => $cleaned ]; } } for ($i = 0; $i < count($candidateLines); $i++) { $c1 = $candidateLines[$i]['cleaned']; if (strpos($c1, 'P') === 0) { for ($j = 0; $j < count($candidateLines); $j++) { if ($i === $j) continue; $c2 = $candidateLines[$j]['cleaned']; if (preg_match('/^[A-Z0-9<]{35,50}$/', $c2)) { if (preg_match('/\d{6}/', $c2)) { return [$candidateLines[$i]['cleaned'], $candidateLines[$j]['cleaned']]; } } } } } return null; } /** * Parse standard 44-character passport MRZ lines. */ private static function parseMrz(string $line1, string $line2): array { $data = self::emptyData(); $line1 = str_pad(substr($line1, 0, 44), 44, '<'); $line2 = str_pad(substr($line2, 0, 44), 44, '<'); $data['issuing_country'] = str_replace('<', '', substr($line1, 2, 3)); $namePart = substr($line1, 5); $parts = explode('<<', $namePart); $surname = isset($parts[0]) ? str_replace('<', ' ', $parts[0]) : ''; $givenNames = isset($parts[1]) ? str_replace('<', ' ', $parts[1]) : ''; $data['given_names'] = trim(strtoupper($givenNames)); if (empty($data['given_names'])) { $data['given_names'] = trim(strtoupper($surname)); } $data['passport_number'] = str_replace('<', '', substr($line2, 0, 9)); $data['nationality'] = str_replace('<', '', substr($line2, 10, 3)); $rawDob = substr($line2, 13, 6); if (ctype_digit($rawDob)) { $data['date_of_birth'] = self::formatMrzDate($rawDob, true); } $rawExpiry = substr($line2, 21, 6); if (ctype_digit($rawExpiry)) { $data['date_of_expiry'] = self::formatMrzDate($rawExpiry, false); } return $data; } private static function formatMrzDate(string $yymmdd, bool $isBirth): string { $yy = intval(substr($yymmdd, 0, 2)); $mm = substr($yymmdd, 2, 2); $dd = substr($yymmdd, 4, 2); $currentYear = intval(date('Y')); $currentYearShort = $currentYear % 100; if ($isBirth) { $year = ($yy > $currentYearShort) ? (1900 + $yy) : (2000 + $yy); } else { $year = ($yy > ($currentYearShort + 20)) ? (1900 + $yy) : (2000 + $yy); } return "{$dd}/{$mm}/{$year}"; } private static function findDateByKeyword(array $lines, array $keywords): ?string { foreach ($lines as $i => $line) { $matched = false; foreach ($keywords as $kw) { if (stripos($line, $kw) !== false) { $matched = true; break; } } if ($matched) { $textToSearch = $line; if (isset($lines[$i + 1])) { $textToSearch .= ' ' . $lines[$i + 1]; } if (preg_match('/(\d{1,2})[\/\-\.](\d{1,2})[\/\-\.](\d{4})/', $textToSearch, $matches)) { $day = str_pad($matches[1], 2, '0', STR_PAD_LEFT); $month = str_pad($matches[2], 2, '0', STR_PAD_LEFT); $year = $matches[3]; return "{$day}/{$month}/{$year}"; } if (preg_match('/(\d{4})[\/\-\.](\d{1,2})[\/\-\.](\d{1,2})/', $textToSearch, $matches)) { $year = $matches[1]; $month = str_pad($matches[2], 2, '0', STR_PAD_LEFT); $day = str_pad($matches[3], 2, '0', STR_PAD_LEFT); return "{$day}/{$month}/{$year}"; } } } return null; } }