324 lines
12 KiB
PHP
324 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class GoogleVisionOcrService
|
|
{
|
|
/**
|
|
* Call Google Cloud Vision API to detect text in the uploaded image.
|
|
*/
|
|
public static function extractPassportData(UploadedFile $file): array
|
|
{
|
|
$apiKey = config('services.google.vision_api_key');
|
|
if (empty($apiKey)) {
|
|
Log::warning('Google Vision API key is not configured.');
|
|
return [
|
|
'success' => 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;
|
|
}
|
|
}
|