diff --git a/.github/workflows/php-cs-fixer.yml b/.github/workflows/php-cs-fixer.yml index 4917b37..2ec0448 100644 --- a/.github/workflows/php-cs-fixer.yml +++ b/.github/workflows/php-cs-fixer.yml @@ -8,7 +8,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ github.head_ref }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 514a49d..7a4493b 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -9,14 +9,14 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest, windows-latest] - php: [8.0] + php: [8.2, 8.3, 8.4, 8.5] stability: [prefer-lowest, prefer-stable] name: P${{ matrix.php }} - ${{ matrix.stability }} - ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml index b20f3b6..94df2c3 100644 --- a/.github/workflows/update-changelog.yml +++ b/.github/workflows/update-changelog.yml @@ -10,7 +10,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: main diff --git a/CHANGELOG.md b/CHANGELOG.md index dc45219..e924adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,39 @@ All notable changes to `mrz-parser` will be documented in this file. +## v2.0.0 - 2026-07-01 + +### Breaking changes + +- Minimum PHP version is now **8.2** (was 8.0). +- `parse()` now returns additional keys (`issuer_code`, `nationality_code`, `sex`, `optional_data`, `valid`, `validation`, `raw`). Existing keys are unchanged. +- `Enums\DocumentType` is now a real backed enum instead of an abstract class with constants. + +### Added + +- **ICAO 9303 check-digit validation.** Each result exposes an overall `valid` flag plus a per-field `validation` map (document number, date of birth, date of expiry, personal number, and composite). +- Richer output: raw ISO alpha-3 codes (`issuer_code`, `nationality_code`), raw `sex` character, `optional_data`, and the normalized `raw` MRZ string. +- **Typed `MrzResult` object** via `MrzParser::read()` — typed properties, `dateOfBirthAsDate()`/`dateOfExpiryAsDate()`, `isExpired()`, `fullName()`, and `toArray()`. +- **Non-throwing entry points** `MrzParser::tryParse()` and `MrzParser::tryRead()` returning `null` on invalid input. +- **Strict check-digit mode** (`parse($text, strict: true)`) throwing `InvalidChecksumException` when a check digit fails. +- **Name transliteration** (`Support\Transliterator`): `fromLatin()` and `matches()` for converting/comparing accented names against the MRZ. +- **French national ID** (2×36 legacy format) support. +- **Laravel integration**: auto-discovered service provider and a `ValidMrz` validation rule. +- A full Pest test suite (parsers, validator, check digits, mappers, DTO, transliteration, Laravel rule) with ~99% coverage. + +### Fixed + +- CRLF / whitespace input is now normalized, so Windows- and OCR-sourced MRZ strings parse correctly instead of failing validation. +- Strict date validation rejects impossible calendar dates (e.g. Feb 30) instead of silently rolling them over. +- A date of birth that would resolve to the future is shifted back a century. +- Removed a PHP 8.4 implicit-nullable deprecation and guarded against `substr(null, ...)` on missing rows. +- Consistent trailing-filler trimming for given names across all document types. + +### Changed + +- Modernized the codebase: `declare(strict_types=1)`, typed properties, `match`-based adapter selection, and a cached country lookup table. +- CI now tests PHP 8.2, 8.3, 8.4 and 8.5; upgraded to Pest 3. + ## v1.2.0 - 2022-06-30 **Full Changelog**: https://github.com/rakibdevs/mrz-parser/compare/v1.1.0...v1.2.0 diff --git a/README.md b/README.md index cbca181..f2a8b52 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,20 @@ # MRZ (Machine Readable Zones) Parser for PHP [![Latest Version on Packagist](https://img.shields.io/packagist/v/rakibdevs/mrz-parser.svg?style=flat-square)](https://packagist.org/packages/rakibdevs/mrz-parser) +[![Tests](https://img.shields.io/github/actions/workflow/status/rakibdevs/mrz-parser/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/rakibdevs/mrz-parser/actions/workflows/run-tests.yml) +[![PHP Version](https://img.shields.io/packagist/dependency-v/rakibdevs/mrz-parser/php?style=flat-square)](https://packagist.org/packages/rakibdevs/mrz-parser) +[![Total Downloads](https://img.shields.io/packagist/dt/rakibdevs/mrz-parser.svg?style=flat-square)](https://packagist.org/packages/rakibdevs/mrz-parser) +[![License](https://img.shields.io/packagist/l/rakibdevs/mrz-parser.svg?style=flat-square)](LICENSE.md) A PHP package for MRZ (Machine Readable Zones) code parser for Passport, Visa & Travel Document (TD1 & TD2). +It reads the machine-readable zone, extracts the document fields, and — new in v2 — verifies the ICAO 9303 check digits so you can tell whether the data is internally consistent. + +## Requirements + +- PHP 8.2 or higher + ## Installation You can install the package via composer: @@ -31,16 +41,97 @@ var_dump($data); "type": "Passport", "card_no": "L898902C3", "issuer": "Utopian", + "issuer_code": "UTO", "date_of_expiry": "2012-04-15", "first_name": "ANNA MARIA", "last_name": "ERIKSSON", "date_of_birth": "1974-08-12", "gender": "Female", + "sex": "F", "personal_number": "ZE184226B", - "nationality": "Utopian" + "optional_data": "ZE184226B", + "nationality": "Utopian", + "nationality_code": "UTO", + "valid": true, + "validation": { + "card_no": true, + "date_of_birth": true, + "date_of_expiry": true, + "personal_number": true, + "composite": true + }, + "raw": "PcardNo; // 'L898902C3' +$mrz->fullName(); // 'ANNA MARIA ERIKSSON' +$mrz->dateOfBirthAsDate(); // DateTimeImmutable +$mrz->isExpired(); // bool (or null when the format has no expiry) +$mrz->valid; // bool +$mrz->toArray(); // the canonical parse() array +``` + +### Non-throwing parsing + +`parse()`/`read()` throw on unrecognised input. Use the `try*` variants to get `null` instead: + +```php +MrzParser::tryParse($text); // ?array +MrzParser::tryRead($text); // ?MrzResult +``` + +### Strict check-digit mode + +Pass `strict: true` to reject documents whose check digits don't verify (throws `InvalidChecksumException`; the `try*` variants return `null`): + +```php +MrzParser::parse($text, strict: true); +MrzParser::read($text, strict: true); +``` + +### Name transliteration + +The MRZ name field only holds `A–Z`. Convert a visual-zone (accented) name to its MRZ form, or compare the two: + +```php +use Rakibdevs\MrzParser\Support\Transliterator; + +Transliterator::fromLatin('Müller'); // 'MUELLER' +Transliterator::fromLatin('Jørgensen'); // 'JOERGENSEN' +Transliterator::matches('MUELLER', 'Müller'); // true +``` + +### Laravel + +The package ships an auto-discovered service provider and a validation rule (requires `illuminate/support`): + +```php +use Rakibdevs\MrzParser\Laravel\Rules\ValidMrz; + +$request->validate([ + 'mrz' => [new ValidMrz()], // must be a parseable MRZ + 'mrz' => [new ValidMrz(strict: true)], // ...with valid check digits +]); +``` + ## Supported Document #### [Passport (TD3)](https://www.icao.int/publications/Documents/9303_p4_cons_en.pdf) ``` @@ -70,6 +161,13 @@ I tests - + ./src + + diff --git a/src/Contracts/ParserInterface.php b/src/Contracts/ParserInterface.php index b868ea4..9c32260 100644 --- a/src/Contracts/ParserInterface.php +++ b/src/Contracts/ParserInterface.php @@ -1,8 +1,10 @@ app->singleton(MrzParser::class, static fn () => new MrzParser()); + $this->app->alias(MrzParser::class, 'mrz-parser'); + } +} diff --git a/src/Laravel/Rules/ValidMrz.php b/src/Laravel/Rules/ValidMrz.php new file mode 100644 index 0000000..19e97c8 --- /dev/null +++ b/src/Laravel/Rules/ValidMrz.php @@ -0,0 +1,29 @@ +validate(['mrz' => [new ValidMrz()]]); + * $request->validate(['mrz' => [new ValidMrz(strict: true)]]); // require check digits + */ +class ValidMrz implements ValidationRule +{ + public function __construct(private readonly bool $strict = false) + { + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || MrzParser::tryParse($value, $this->strict) === null) { + $fail('The :attribute is not a valid machine-readable zone.'); + } + } +} diff --git a/src/MrzParser.php b/src/MrzParser.php index adf6e32..e53d01d 100644 --- a/src/MrzParser.php +++ b/src/MrzParser.php @@ -1,72 +1,118 @@ text = $text; + $this->text = MrzNormalizer::normalize($text ?? ''); } - protected function setAdapter() + protected function setAdapter(): self { - switch ($this->documentType) { - case DocumentType::PASSPORT: - $this->adapter = new PassportMrzParser(); - - break; - case DocumentType::VISA: - $this->adapter = new VisaMrzParser(); - - break; - case DocumentType::TRAVEL_DOCUMENT_1: - $this->adapter = new TravelDocument1MrzParser(); - - break; - case DocumentType::TRAVEL_DOCUMENT_2: - $this->adapter = new TravelDocument2MrzParser(); - - break; - } + $this->adapter = match ($this->documentType) { + DocumentType::PASSPORT => new PassportMrzParser(), + DocumentType::VISA => new VisaMrzParser(), + DocumentType::TRAVEL_DOCUMENT_1 => new TravelDocument1MrzParser(), + DocumentType::TRAVEL_DOCUMENT_2 => new TravelDocument2MrzParser(), + DocumentType::FRENCH_ID => new FrenchIdMrzParser(), + default => null, + }; return $this; } - protected function validate() + protected function validate(): self { $this->documentType = (new ValidateDocument($this->text))->validate(); return $this; } - protected function get(): ?array + protected function get(): array { - if (empty($this->adapter)) { - throw new NotSupportedException("This format is not supported yet!"); + if ($this->adapter === null) { + throw new NotSupportedException('This format is not supported yet!'); } return $this->adapter->parse($this->text); } - public static function parse(string $text): array + /** + * Parse an MRZ into the canonical associative array. + * + * @param bool $strict when true, throw if any check digit fails + * + * @throws InvalidFormatException when the input is not a recognised MRZ + * @throws NotSupportedException when the format has no parser + * @throws InvalidChecksumException when $strict and a check digit fails + * + * @return array + */ + public static function parse(string $text, bool $strict = false): array { - return (new static($text)) + $data = (new static($text)) ->validate() ->setAdapter() ->get(); + + if ($strict && ($data['valid'] ?? false) === false) { + throw new InvalidChecksumException('One or more MRZ check digits are invalid.'); + } + + return $data; + } + + /** + * Parse an MRZ into a typed {@see MrzResult}. + */ + public static function read(string $text, bool $strict = false): MrzResult + { + return MrzResult::fromArray(static::parse($text, $strict)); + } + + /** + * Parse an MRZ, returning null instead of throwing on invalid input. + * + * @return array|null + */ + public static function tryParse(string $text, bool $strict = false): ?array + { + try { + return static::parse($text, $strict); + } catch (InvalidFormatException | NotSupportedException | InvalidChecksumException) { + return null; + } + } + + /** + * Read an MRZ into a typed {@see MrzResult}, or null on invalid input. + */ + public static function tryRead(string $text, bool $strict = false): ?MrzResult + { + $data = static::tryParse($text, $strict); + + return $data === null ? null : MrzResult::fromArray($data); } } diff --git a/src/MrzResult.php b/src/MrzResult.php new file mode 100644 index 0000000..7c69f9a --- /dev/null +++ b/src/MrzResult.php @@ -0,0 +1,124 @@ + $validation + * @param array $attributes the original parse() array + */ + public function __construct( + public readonly string $type, + public readonly ?string $cardNo, + public readonly ?string $issuer, + public readonly ?string $issuerCode, + public readonly ?string $dateOfExpiry, + public readonly ?string $firstName, + public readonly ?string $lastName, + public readonly ?string $dateOfBirth, + public readonly ?string $gender, + public readonly ?string $sex, + public readonly ?string $personalNumber, + public readonly ?string $optionalData, + public readonly ?string $nationality, + public readonly ?string $nationalityCode, + public readonly bool $valid, + public readonly array $validation, + public readonly string $raw, + private readonly array $attributes, + ) { + } + + /** + * @param array $data a MrzParser::parse() result + */ + public static function fromArray(array $data): self + { + return new self( + type: (string) ($data['type'] ?? ''), + cardNo: $data['card_no'] ?? null, + issuer: $data['issuer'] ?? null, + issuerCode: $data['issuer_code'] ?? null, + dateOfExpiry: $data['date_of_expiry'] ?? null, + firstName: $data['first_name'] ?? null, + lastName: $data['last_name'] ?? null, + dateOfBirth: $data['date_of_birth'] ?? null, + gender: $data['gender'] ?? null, + sex: $data['sex'] ?? null, + personalNumber: $data['personal_number'] ?? null, + optionalData: $data['optional_data'] ?? null, + nationality: $data['nationality'] ?? null, + nationalityCode: $data['nationality_code'] ?? null, + valid: (bool) ($data['valid'] ?? false), + validation: $data['validation'] ?? [], + raw: (string) ($data['raw'] ?? ''), + attributes: $data, + ); + } + + public function dateOfBirthAsDate(): ?DateTimeImmutable + { + return $this->toDate($this->dateOfBirth); + } + + public function dateOfExpiryAsDate(): ?DateTimeImmutable + { + return $this->toDate($this->dateOfExpiry); + } + + /** + * Whether the document is expired at the given moment (defaults to now). + * Returns null when the format carries no expiry date. + */ + public function isExpired(?DateTimeImmutable $at = null): ?bool + { + $expiry = $this->dateOfExpiryAsDate(); + + if ($expiry === null) { + return null; + } + + return $expiry < ($at ?? new DateTimeImmutable('today')); + } + + /** + * Combined "First Last" name, or null when neither part is present. + */ + public function fullName(): ?string + { + $name = trim(implode(' ', array_filter([$this->firstName, $this->lastName]))); + + return $name === '' ? null : $name; + } + + /** + * @return array the original MrzParser::parse() array + */ + public function toArray(): array + { + return $this->attributes; + } + + private function toDate(?string $value): ?DateTimeImmutable + { + if ($value === null || $value === '') { + return null; + } + + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); + + return $date === false ? null : $date; + } +} diff --git a/src/Parser/FrenchIdMrzParser.php b/src/Parser/FrenchIdMrzParser.php new file mode 100644 index 0000000..2a1cd78 --- /dev/null +++ b/src/Parser/FrenchIdMrzParser.php @@ -0,0 +1,156 @@ +text = MrzNormalizer::normalize($text); + + return $this; + } + + protected function extract(): self + { + $rows = MrzNormalizer::rows($this->text); + $this->firstLine = $rows[0] ?? ''; + $this->secondLine = $rows[1] ?? ''; + + return $this; + } + + protected function getCardNo(): ?string + { + return rtrim(substr($this->secondLine, 0, 12), '<') ?: null; + } + + protected function getIssuer(): ?string + { + return $this->mapCountry($this->getIssuerCode()); + } + + protected function getIssuerCode(): ?string + { + return rtrim(substr($this->firstLine, 2, 3), '<') ?: null; + } + + protected function getLastName(): ?string + { + return $this->cleanName(substr($this->firstLine, 5, 25)); + } + + protected function getFirstName(): ?string + { + return $this->cleanName(substr($this->secondLine, 13, 14)); + } + + protected function getDateOfBirth(): ?string + { + return $this->formatDate(substr($this->secondLine, 27, 6), 'Y-m-d', true); + } + + protected function getGender(): ?string + { + return $this->mapGender(substr($this->secondLine, 34, 1)); + } + + protected function getSex(): ?string + { + $sex = substr($this->secondLine, 34, 1); + + return in_array($sex, ['M', 'F', 'X'], true) ? $sex : null; + } + + protected function getOptionalData(): ?string + { + return rtrim(substr($this->firstLine, 30, 6), '<') ?: null; + } + + /** + * @return array + */ + protected function getValidation(): array + { + $composite = $this->firstLine . substr($this->secondLine, 0, 35); + + return [ + 'card_no' => CheckDigit::verify(substr($this->secondLine, 0, 12), substr($this->secondLine, 12, 1)), + 'date_of_birth' => CheckDigit::verify(substr($this->secondLine, 27, 6), substr($this->secondLine, 33, 1)), + 'composite' => CheckDigit::verify($composite, substr($this->secondLine, 35, 1)), + ]; + } + + protected function get(): array + { + $validation = $this->getValidation(); + + return [ + 'type' => 'National ID (France)', + 'card_no' => $this->getCardNo(), + 'issuer' => $this->getIssuer(), + 'issuer_code' => $this->getIssuerCode(), + 'date_of_expiry' => null, + 'first_name' => $this->getFirstName(), + 'last_name' => $this->getLastName(), + 'date_of_birth' => $this->getDateOfBirth(), + 'gender' => $this->getGender(), + 'sex' => $this->getSex(), + 'optional_data' => $this->getOptionalData(), + 'nationality' => $this->getIssuer(), + 'nationality_code' => $this->getIssuerCode(), + 'valid' => ! in_array(false, $validation, true), + 'validation' => $validation, + 'raw' => $this->text, + ]; + } + + public function parse(string $text): ?array + { + return $this + ->setText($text) + ->extract() + ->get(); + } + + /** + * Trim filler and collapse '<' separators into single spaces. + */ + private function cleanName(string $field): ?string + { + $name = trim(preg_replace('/\s+/', ' ', str_replace('<', ' ', $field)) ?? ''); + + return $name === '' ? null : $name; + } +} diff --git a/src/Parser/PassportMrzParser.php b/src/Parser/PassportMrzParser.php index 7a2c1ca..4698ef7 100644 --- a/src/Parser/PassportMrzParser.php +++ b/src/Parser/PassportMrzParser.php @@ -1,47 +1,43 @@ text = $text; + $this->text = MrzNormalizer::normalize($text); return $this; } - /** - * Set Name String - * - * @return self - */ protected function setNameString(): self { $this->nameString = explode('<<', substr($this->firstLine, 5)); @@ -49,154 +45,134 @@ protected function setNameString(): self return $this; } - /** - * Extract information - * - * @return self - */ protected function extract(): self { - $text = explode("\n", $this->text); - $this->firstLine = $text[0] ?? null; - $this->secondLine = $text[1] ?? null; + $rows = MrzNormalizer::rows($this->text); + $this->firstLine = $rows[0] ?? ''; + $this->secondLine = $rows[1] ?? ''; $this->setNameString(); return $this; } /** - * Second row first 9 character alpha+num+< Passport number - * - * @return null|string + * Second row first 9 character alpha+num+< Passport number */ protected function getCardNo(): ?string { - $cardNo = substr($this->secondLine, 0, 9); - $cardNo = chop($cardNo, "<"); // remove extra '<' from card no - - return $cardNo; + return rtrim(substr($this->secondLine, 0, 9), '<') ?: null; } - /** - * Get Passport Issuer - * - * @return null|string - */ protected function getIssuer(): ?string { - $issuer = chop(substr($this->firstLine, 2, 3), "<"); + return $this->mapCountry($this->getIssuerCode()); + } - return $this->mapCountry($issuer); + protected function getIssuerCode(): ?string + { + return rtrim(substr($this->firstLine, 2, 3), '<') ?: null; } /** * Get Date of Expiry - * Second row 22–27 character: (YYMMDD) - * - * @return null|string + * Second row 22–27 character: (YYMMDD) */ protected function getDateOfExpiry(): ?string { - $date = substr($this->secondLine, 21, 6); - - return $date ? $this->formatDate($date) : null; + return $this->formatDate(substr($this->secondLine, 21, 6)); } /** * Get Date of Birth - * Second row 14–19 character: (YYMMDD) - * - * @return null|string + * Second row 14–19 character: (YYMMDD) */ protected function getDateOfBirth(): ?string { - $date = substr($this->secondLine, 13, 6); - - return $date ? $this->formatDate($date) : null; + return $this->formatDate(substr($this->secondLine, 13, 6), 'Y-m-d', true); } /** - * Get First Name from Name String - * For Ex, MARTINA<<<<<<<<<<<<<<<<<<<<<<<<<< - * - * @return null|string + * Get Gender from Position 21, M/F/< */ - protected function getFirstName(): ?string + protected function getGender(): ?string { - return isset($this->nameString[1]) ? str_replace('<', ' ', chop($this->nameString[1], "<")) : null; + return $this->mapGender(substr($this->secondLine, 20, 1)); } - /** - * Get Last Name from Name String - * - * @return null|string - */ - protected function getLastName(): ?string + protected function getSex(): ?string { - return $this->nameString[0] ?? null; - } + $sex = substr($this->secondLine, 20, 1); - /** - * Get Gender from Position 21, M/F/< - * - * @return null|string - * - */ - protected function getGender(): ?string - { - return $this->mapGender(substr($this->secondLine, 20, 1)); + return in_array($sex, ['M', 'F'], true) ? $sex : null; } /** * Get Personal Number * 29–42 alpha+num+< (may be used by the issuing country as it desires) - * - * @return null|string */ protected function getPersonalNumber(): ?string { - return chop(substr($this->secondLine, 28, 14), "<"); + return rtrim(substr($this->secondLine, 28, 14), '<') ?: null; } - /** - * Get Nationality - * - * @return null|string - */ protected function getNationality(): ?string { - $code = chop(substr($this->secondLine, 10, 3), "<"); + return $this->mapCountry($this->getNationalityCode()); + } - return $this->mapCountry($code); + protected function getNationalityCode(): ?string + { + return rtrim(substr($this->secondLine, 10, 3), '<') ?: null; } /** - * Get Output from MRZ + * Verify the ICAO 9303 check digits for the TD3 second row. * - * @return array + * @return array */ + protected function getValidation(): array + { + $line = $this->secondLine; + + $composite = substr($line, 0, 10) + . substr($line, 13, 7) + . substr($line, 21, 7) + . substr($line, 28, 15); + + return [ + 'card_no' => CheckDigit::verify(substr($line, 0, 9), substr($line, 9, 1)), + 'date_of_birth' => CheckDigit::verify(substr($line, 13, 6), substr($line, 19, 1)), + 'date_of_expiry' => CheckDigit::verify(substr($line, 21, 6), substr($line, 27, 1)), + 'personal_number' => CheckDigit::verify(substr($line, 28, 14), substr($line, 42, 1)), + 'composite' => CheckDigit::verify($composite, substr($line, 43, 1)), + ]; + } + protected function get(): array { + $validation = $this->getValidation(); + return [ 'type' => 'Passport', 'card_no' => $this->getCardNo(), 'issuer' => $this->getIssuer(), + 'issuer_code' => $this->getIssuerCode(), 'date_of_expiry' => $this->getDateOfExpiry(), - 'first_name' => $this->getFirstName(), - 'last_name' => $this->getLastName(), + 'first_name' => $this->firstNameFromNameString(), + 'last_name' => $this->lastNameFromNameString(), 'date_of_birth' => $this->getDateOfBirth(), 'gender' => $this->getGender(), + 'sex' => $this->getSex(), 'personal_number' => $this->getPersonalNumber(), + 'optional_data' => $this->getPersonalNumber(), 'nationality' => $this->getNationality(), + 'nationality_code' => $this->getNationalityCode(), + 'valid' => ! in_array(false, $validation, true), + 'validation' => $validation, + 'raw' => $this->text, ]; } - /** - * Parse MRZ to Json Data - * - * @param string $text - * @return null|array - */ public function parse(string $text): ?array { return $this diff --git a/src/Parser/TravelDocument1MrzParser.php b/src/Parser/TravelDocument1MrzParser.php index ce6d078..b1cec62 100644 --- a/src/Parser/TravelDocument1MrzParser.php +++ b/src/Parser/TravelDocument1MrzParser.php @@ -1,27 +1,31 @@ text = $text; + $this->text = MrzNormalizer::normalize($text); return $this; } - /** - * Set Name String - */ protected function setNameString(): self { $this->nameString = explode('<<', $this->thirdLine); @@ -48,147 +49,143 @@ protected function setNameString(): self return $this; } - /** - * Extract information - */ protected function extract(): self { - $text = explode("\n", $this->text); - $this->firstLine = $text[0] ?? null; - $this->secondLine = $text[1] ?? null; - $this->thirdLine = $text[2] ?? null; + $rows = MrzNormalizer::rows($this->text); + $this->firstLine = $rows[0] ?? ''; + $this->secondLine = $rows[1] ?? ''; + $this->thirdLine = $rows[2] ?? ''; $this->setNameString(); return $this; } /** - * Get Type beased on first two string - * - * Type, This is at the discretion of the issuing state or authority, - * but 1–2 should be AC for Crew Member Certificates and V is not allowed as 2nd character. - * ID or I< are typically used for nationally issued ID cards and IP for passport cards. + * Type is at the discretion of the issuing state. 1–2 is AC for Crew + * Member Certificates; ID / I< for national ID cards; IP for passport cards. */ - protected function getType() + protected function getType(): string { - $firstTwoCharacter = substr($this->firstLine, 0, 2); - - return match ($firstTwoCharacter) { + return match (substr($this->firstLine, 0, 2)) { 'AC' => 'Crew Member Certificates', 'I<' => 'National ID', 'IP' => 'Passport', - default => "Travel Document (TD1)" + default => 'Travel Document (TD1)', }; } /** * Get Document Number - * 6–14 alpha+num+< Document number + * First row 6–14 alpha+num+< Document number */ protected function getCardNo(): ?string { - $cardNo = substr($this->firstLine, 5, 9); - $cardNo = chop($cardNo, "<"); // remove extra '<' from card no - - return $cardNo; + return rtrim(substr($this->firstLine, 5, 9), '<') ?: null; } - /** - * Get Document Issuer - */ protected function getIssuer(): ?string { - $issuer = chop(substr($this->firstLine, 2, 3), "<"); + return $this->mapCountry($this->getIssuerCode()); + } - return $this->mapCountry($issuer); + protected function getIssuerCode(): ?string + { + return rtrim(substr($this->firstLine, 2, 3), '<') ?: null; } /** * Get Date of Expiry - * Second row 9-14 character: (YYMMDD) + * Second row 9-14 character: (YYMMDD) */ protected function getDateOfExpiry(): ?string { - $date = substr($this->secondLine, 8, 6); - - return $date ? $this->formatDate($date) : null; + return $this->formatDate(substr($this->secondLine, 8, 6)); } /** * Get Date of Birth - * Second row 1-6 character: (YYMMDD) + * Second row 1-6 character: (YYMMDD) */ protected function getDateOfBirth(): ?string { - $date = substr($this->secondLine, 0, 6); - - return $date ? $this->formatDate($date) : null; + return $this->formatDate(substr($this->secondLine, 0, 6), 'Y-m-d', true); } - /** - * Get First Name from Name String - * - * <nameString[1]) ? str_replace("<", " ", $this->nameString[1]) : null; + return $this->mapGender(substr($this->secondLine, 7, 1)); } - /** - * Get Last Name from Name String - */ - protected function getLastName(): ?string + protected function getSex(): ?string { - return $this->nameString[0] ?? null; + $sex = substr($this->secondLine, 7, 1); + + return in_array($sex, ['M', 'F'], true) ? $sex : null; } - /** - * Get Gender - * Position 8, M/F/< - * - */ - protected function getGender(): ?string + protected function getNationality(): ?string { - return $this->mapGender(substr($this->secondLine, 7, 1)); + return $this->mapCountry($this->getNationalityCode()); } - /** - * Get Nationality - */ - protected function getNationality(): ?string + protected function getNationalityCode(): ?string { - $code = chop(substr($this->secondLine, 15, 3), "<"); + return rtrim(substr($this->secondLine, 15, 3), '<') ?: null; + } - return $this->mapCountry($code); + protected function getOptionalData(): ?string + { + return rtrim(substr($this->firstLine, 15, 15), '<') ?: null; } /** - * Get Output from MRZ + * Verify the ICAO 9303 TD1 check digits (document number on line 1, date + * of birth / expiry on line 2, and the composite over all fields). * - * @return array + * @return array */ + protected function getValidation(): array + { + $line1 = $this->firstLine; + $line2 = $this->secondLine; + + $composite = substr($line1, 5, 25) + . substr($line2, 0, 7) + . substr($line2, 8, 7) + . substr($line2, 18, 11); + + return [ + 'card_no' => CheckDigit::verify(substr($line1, 5, 9), substr($line1, 14, 1)), + 'date_of_birth' => CheckDigit::verify(substr($line2, 0, 6), substr($line2, 6, 1)), + 'date_of_expiry' => CheckDigit::verify(substr($line2, 8, 6), substr($line2, 14, 1)), + 'composite' => CheckDigit::verify($composite, substr($line2, 29, 1)), + ]; + } + protected function get(): array { + $validation = $this->getValidation(); + return [ 'type' => $this->getType(), 'card_no' => $this->getCardNo(), 'issuer' => $this->getIssuer(), + 'issuer_code' => $this->getIssuerCode(), 'date_of_expiry' => $this->getDateOfExpiry(), - 'first_name' => $this->getFirstName(), - 'last_name' => $this->getLastName(), + 'first_name' => $this->firstNameFromNameString(), + 'last_name' => $this->lastNameFromNameString(), 'date_of_birth' => $this->getDateOfBirth(), 'gender' => $this->getGender(), + 'sex' => $this->getSex(), + 'optional_data' => $this->getOptionalData(), 'nationality' => $this->getNationality(), + 'nationality_code' => $this->getNationalityCode(), + 'valid' => ! in_array(false, $validation, true), + 'validation' => $validation, + 'raw' => $this->text, ]; } - /** - * Parse MRZ to Json Data - * - * @param string $text - * @return null|array - */ public function parse(string $text): ?array { return $this diff --git a/src/Parser/TravelDocument2MrzParser.php b/src/Parser/TravelDocument2MrzParser.php index 0f089eb..00223d1 100644 --- a/src/Parser/TravelDocument2MrzParser.php +++ b/src/Parser/TravelDocument2MrzParser.php @@ -1,25 +1,29 @@ text = $text; + $this->text = MrzNormalizer::normalize($text); return $this; } - /** - * Set Name String - */ protected function setNameString(): self { $this->nameString = explode('<<', substr($this->firstLine, 5)); @@ -45,146 +46,139 @@ protected function setNameString(): self return $this; } - /** - * Extract information - */ protected function extract(): self { - $text = explode("\n", $this->text); - $this->firstLine = $text[0] ?? null; - $this->secondLine = $text[1] ?? null; + $rows = MrzNormalizer::rows($this->text); + $this->firstLine = $rows[0] ?? ''; + $this->secondLine = $rows[1] ?? ''; $this->setNameString(); return $this; } /** - * Get Type beased on first two string - * - * Type, This is at the discretion of the issuing state or authority, - * but 1–2 should be AC for Crew Member Certificates and V is not allowed as 2nd character. - * ID or I< are typically used for nationally issued ID cards and IP for passport cards. + * Type is at the discretion of the issuing state. 1–2 is AC for Crew + * Member Certificates; ID / I< for national ID cards; IP for passport cards. */ - protected function getType() + protected function getType(): string { - $firstTwoCharacter = substr($this->firstLine, 0, 2); - - return match ($firstTwoCharacter) { + return match (substr($this->firstLine, 0, 2)) { 'AC' => 'Crew Member Certificates', 'I<' => 'National ID', 'IP' => 'Passport', - default => "Travel Document (TD2)" + default => 'Travel Document (TD2)', }; } /** * Get Document Number - * 1-9 alpha+num+< Document number + * Second row 1-9 alpha+num+< Document number */ protected function getCardNo(): ?string { - $cardNo = substr($this->secondLine, 0, 9); - $cardNo = chop($cardNo, "<"); // remove extra '<' from card no - - return $cardNo; + return rtrim(substr($this->secondLine, 0, 9), '<') ?: null; } - /** - * Get Document Issuer - */ protected function getIssuer(): ?string { - $issuer = chop(substr($this->firstLine, 2, 3), "<"); + return $this->mapCountry($this->getIssuerCode()); + } - return $this->mapCountry($issuer); + protected function getIssuerCode(): ?string + { + return rtrim(substr($this->firstLine, 2, 3), '<') ?: null; } /** * Get Date of Expiry - * Second row 22-27 character: (YYMMDD) + * Second row 22-27 character: (YYMMDD) */ protected function getDateOfExpiry(): ?string { - $date = substr($this->secondLine, 21, 6); - - return $date ? $this->formatDate($date) : null; + return $this->formatDate(substr($this->secondLine, 21, 6)); } /** * Get Date of Birth - * Second row 14-19 character: (YYMMDD) + * Second row 14-19 character: (YYMMDD) */ protected function getDateOfBirth(): ?string { - $date = substr($this->secondLine, 13, 6); - - return $date ? $this->formatDate($date) : null; + return $this->formatDate(substr($this->secondLine, 13, 6), 'Y-m-d', true); } - /** - * Get First Name from Name String - * - * <nameString[1]) ? str_replace("<", " ", $this->nameString[1]) : null; + return $this->mapGender(substr($this->secondLine, 20, 1)); } - /** - * Get Last Name from Name String - */ - protected function getLastName(): ?string + protected function getSex(): ?string { - return $this->nameString[0] ?? null; + $sex = substr($this->secondLine, 20, 1); + + return in_array($sex, ['M', 'F'], true) ? $sex : null; } - /** - * Get Gender - * Position 21, M/F/< - * - */ - protected function getGender(): ?string + protected function getNationality(): ?string { - return $this->mapGender(substr($this->secondLine, 20, 1)); + return $this->mapCountry($this->getNationalityCode()); } - /** - * Get Nationality - */ - protected function getNationality(): ?string + protected function getNationalityCode(): ?string { - $code = chop(substr($this->secondLine, 10, 3), "<"); + return rtrim(substr($this->secondLine, 10, 3), '<') ?: null; + } - return $this->mapCountry($code); + protected function getOptionalData(): ?string + { + return rtrim(substr($this->secondLine, 28, 7), '<') ?: null; } /** - * Get Output from MRZ + * Verify the ICAO 9303 TD2 check digits for the second row. * - * @return array + * @return array */ + protected function getValidation(): array + { + $line = $this->secondLine; + + $composite = substr($line, 0, 10) + . substr($line, 13, 7) + . substr($line, 21, 14); + + return [ + 'card_no' => CheckDigit::verify(substr($line, 0, 9), substr($line, 9, 1)), + 'date_of_birth' => CheckDigit::verify(substr($line, 13, 6), substr($line, 19, 1)), + 'date_of_expiry' => CheckDigit::verify(substr($line, 21, 6), substr($line, 27, 1)), + 'composite' => CheckDigit::verify($composite, substr($line, 35, 1)), + ]; + } + protected function get(): array { + $validation = $this->getValidation(); + return [ 'type' => $this->getType(), 'card_no' => $this->getCardNo(), 'issuer' => $this->getIssuer(), + 'issuer_code' => $this->getIssuerCode(), 'date_of_expiry' => $this->getDateOfExpiry(), - 'first_name' => $this->getFirstName(), - 'last_name' => $this->getLastName(), + 'first_name' => $this->firstNameFromNameString(), + 'last_name' => $this->lastNameFromNameString(), 'date_of_birth' => $this->getDateOfBirth(), 'gender' => $this->getGender(), + 'sex' => $this->getSex(), + 'optional_data' => $this->getOptionalData(), 'nationality' => $this->getNationality(), + 'nationality_code' => $this->getNationalityCode(), + 'valid' => ! in_array(false, $validation, true), + 'validation' => $validation, + 'raw' => $this->text, ]; } - /** - * Parse MRZ to Json Data - * - * @param string $text - * @return null|array - */ public function parse(string $text): ?array { return $this diff --git a/src/Parser/VisaMrzParser.php b/src/Parser/VisaMrzParser.php index 469c6e0..1582058 100644 --- a/src/Parser/VisaMrzParser.php +++ b/src/Parser/VisaMrzParser.php @@ -1,42 +1,43 @@ text = $text; + $this->text = MrzNormalizer::normalize($text); return $this; } - /** - * Set Name String - */ protected function setNameString(): self { $this->nameString = explode('<<', substr($this->firstLine, 5)); @@ -44,127 +45,123 @@ protected function setNameString(): self return $this; } - /** - * Extract information - */ protected function extract(): self { - $text = explode("\n", $this->text); - $this->firstLine = $text[0] ?? null; - $this->secondLine = $text[1] ?? null; + $rows = MrzNormalizer::rows($this->text); + $this->firstLine = $rows[0] ?? ''; + $this->secondLine = $rows[1] ?? ''; $this->setNameString(); return $this; } - /** - * Second row first 9 character alpha+num+< Visa number - */ protected function getCardNo(): ?string { - $cardNo = substr($this->secondLine, 0, 9); - $cardNo = chop($cardNo, "<"); // remove extra '<' from card no - - return $cardNo; + return rtrim(substr($this->secondLine, 0, 9), '<') ?: null; } - /** - * Get Visa Issuer - */ protected function getIssuer(): ?string { - $issuer = chop(substr($this->firstLine, 2, 3), "<"); + return $this->mapCountry($this->getIssuerCode()); + } - return $this->mapCountry($issuer); + protected function getIssuerCode(): ?string + { + return rtrim(substr($this->firstLine, 2, 3), '<') ?: null; } /** * Get Date of Expiry - * Second row 22–27 character: (YYMMDD) + * Second row 22–27 character: (YYMMDD) */ protected function getDateOfExpiry(): ?string { - $date = substr($this->secondLine, 21, 6); - - return $date ? $this->formatDate($date) : null; + return $this->formatDate(substr($this->secondLine, 21, 6)); } /** * Get Date of Birth - * Second row 14–19 character: (YYMMDD) + * Second row 14–19 character: (YYMMDD) */ protected function getDateOfBirth(): ?string { - $date = substr($this->secondLine, 13, 6); + return $this->formatDate(substr($this->secondLine, 13, 6), 'Y-m-d', true); + } - return $date ? $this->formatDate($date) : null; + protected function getGender(): ?string + { + return $this->mapGender(substr($this->secondLine, 20, 1)); } - /** - * Get First Name from Name String - * - * MARTINA<<<<<<<<<<<<<<<<<<<<<<<<<< - */ - protected function getFirstName(): ?string + protected function getSex(): ?string { - return isset($this->nameString[1]) ? str_replace('<', ' ', chop($this->nameString[1], "<")) : null; + $sex = substr($this->secondLine, 20, 1); + + return in_array($sex, ['M', 'F'], true) ? $sex : null; } - /** - * Get Last Name from Name String - * - */ - protected function getLastName(): ?string + protected function getNationality(): ?string { - return $this->nameString[0] ?? null; + return $this->mapCountry($this->getNationalityCode()); } - /** - * Get Gender - * Position 21, M/F/< - * - */ - protected function getGender(): ?string + protected function getNationalityCode(): ?string { - return $this->mapGender(substr($this->secondLine, 20, 1)); + return rtrim(substr($this->secondLine, 10, 3), '<') ?: null; } /** - * Get Nationality + * Optional data starts at position 29 (index 28) and runs to the end of + * the line: 16 characters on an MRV-A (44), 8 on an MRV-B (36). */ - protected function getNationality(): ?string + protected function getOptionalData(): ?string { - $code = chop(substr($this->secondLine, 10, 3), "<"); + $length = strlen($this->secondLine) === 36 ? 8 : 16; - return $this->mapCountry($code); + return rtrim(substr($this->secondLine, 28, $length), '<') ?: null; } /** - * Get Output from MRZ + * Verify the ICAO 9303 check digits present on a visa (MRV has no + * composite check digit). * - * @return array + * @return array */ + protected function getValidation(): array + { + $line = $this->secondLine; + + return [ + 'card_no' => CheckDigit::verify(substr($line, 0, 9), substr($line, 9, 1)), + 'date_of_birth' => CheckDigit::verify(substr($line, 13, 6), substr($line, 19, 1)), + 'date_of_expiry' => CheckDigit::verify(substr($line, 21, 6), substr($line, 27, 1)), + ]; + } + protected function get(): array { + $validation = $this->getValidation(); + return [ 'type' => 'Visa', 'card_no' => $this->getCardNo(), 'issuer' => $this->getIssuer(), + 'issuer_code' => $this->getIssuerCode(), 'date_of_expiry' => $this->getDateOfExpiry(), - 'first_name' => $this->getFirstName(), - 'last_name' => $this->getLastName(), + 'first_name' => $this->firstNameFromNameString(), + 'last_name' => $this->lastNameFromNameString(), 'date_of_birth' => $this->getDateOfBirth(), 'gender' => $this->getGender(), + 'sex' => $this->getSex(), + 'optional_data' => $this->getOptionalData(), 'nationality' => $this->getNationality(), + 'nationality_code' => $this->getNationalityCode(), + 'valid' => ! in_array(false, $validation, true), + 'validation' => $validation, + 'raw' => $this->text, ]; } - /** - * Parse MRZ to Json Data - * - * @param string $text - * @return null|array - */ public function parse(string $text): ?array { return $this diff --git a/src/Support/CheckDigit.php b/src/Support/CheckDigit.php new file mode 100644 index 0000000..d391bf1 --- /dev/null +++ b/src/Support/CheckDigit.php @@ -0,0 +1,70 @@ + */ + private const WEIGHTS = [7, 3, 1]; + + /** + * Compute the check digit for a field. + */ + public static function calculate(string $value): int + { + $sum = 0; + + foreach (str_split(strtoupper($value)) as $index => $char) { + $sum += self::charValue($char) * self::WEIGHTS[$index % 3]; + } + + return $sum % 10; + } + + /** + * Verify a field against its expected check digit. + * + * A filler check digit ('<') is only accepted when the field itself is + * entirely filler, matching ICAO's handling of unused optional fields. + */ + public static function verify(string $value, string $expected): bool + { + $expected = strtoupper($expected); + + if ($expected === '<') { + return trim($value, '<') === ''; + } + + if (! ctype_digit($expected)) { + return false; + } + + return self::calculate($value) === (int) $expected; + } + + private static function charValue(string $char): int + { + if ($char === '<') { + return 0; + } + + if (ctype_digit($char)) { + return (int) $char; + } + + if (ctype_alpha($char)) { + return ord($char) - 55; // 'A' (65) => 10 ... 'Z' (90) => 35 + } + + return 0; + } +} diff --git a/src/Support/MrzNormalizer.php b/src/Support/MrzNormalizer.php new file mode 100644 index 0000000..0353046 --- /dev/null +++ b/src/Support/MrzNormalizer.php @@ -0,0 +1,38 @@ + $line !== '')); + + return strtoupper(implode("\n", $lines)); + } + + /** + * Normalize and split the MRZ into its individual rows. + * + * @return array + */ + public static function rows(string $text): array + { + $normalized = self::normalize($text); + + return $normalized === '' ? [] : explode("\n", $normalized); + } +} diff --git a/src/Support/Transliterator.php b/src/Support/Transliterator.php new file mode 100644 index 0000000..001b789 --- /dev/null +++ b/src/Support/Transliterator.php @@ -0,0 +1,89 @@ + 'OE', + * 'ß' -> 'SS', 'Æ' -> 'AE'. This mapping is deterministic in the Latin -> MRZ + * direction but NOT reversible (an MRZ 'OE' may be a genuine "OE" or a + * transliterated 'Ö'), so only {@see self::fromLatin()} is provided. Use + * {@see self::matches()} to compare a visual-zone name against an MRZ name. + */ +final class Transliterator +{ + /** + * Multi-character substitutions (applied before single-character ones). + * + * @var array + */ + private const DIGRAPHS = [ + 'Æ' => 'AE', 'æ' => 'AE', + 'Œ' => 'OE', 'œ' => 'OE', + 'ß' => 'SS', + 'IJ' => 'IJ', 'ij' => 'IJ', + 'Ø' => 'OE', 'ø' => 'OE', + 'Å' => 'AA', 'å' => 'AA', + 'Ð' => 'D', 'ð' => 'D', + 'Þ' => 'TH', 'þ' => 'TH', + ]; + + /** + * Diacritic folding for the remaining accented letters. + * + * @var array + */ + private const DIACRITICS = [ + 'À' => 'A', 'Á' => 'A', 'Â' => 'A', 'Ã' => 'A', 'Ä' => 'A', 'Ā' => 'A', 'Ă' => 'A', 'Ą' => 'A', + 'Ç' => 'C', 'Ć' => 'C', 'Č' => 'C', 'Ĉ' => 'C', 'Ċ' => 'C', + 'È' => 'E', 'É' => 'E', 'Ê' => 'E', 'Ë' => 'E', 'Ē' => 'E', 'Ĕ' => 'E', 'Ė' => 'E', 'Ę' => 'E', 'Ě' => 'E', + 'Ì' => 'I', 'Í' => 'I', 'Î' => 'I', 'Ï' => 'I', 'Ī' => 'I', 'Ĭ' => 'I', 'Į' => 'I', 'İ' => 'I', + 'Ñ' => 'N', 'Ń' => 'N', 'Ň' => 'N', 'Ņ' => 'N', + 'Ò' => 'O', 'Ó' => 'O', 'Ô' => 'O', 'Õ' => 'O', 'Ö' => 'OE', 'Ō' => 'O', 'Ŏ' => 'O', 'Ő' => 'O', + 'Ù' => 'U', 'Ú' => 'U', 'Û' => 'U', 'Ü' => 'UE', 'Ū' => 'U', 'Ŭ' => 'U', 'Ů' => 'U', 'Ű' => 'U', 'Ų' => 'U', + 'Ý' => 'Y', 'Ÿ' => 'Y', + 'Ł' => 'L', 'Ĺ' => 'L', 'Ľ' => 'L', + 'Ś' => 'S', 'Š' => 'S', 'Ş' => 'S', 'Ŝ' => 'S', + 'Ź' => 'Z', 'Ż' => 'Z', 'Ž' => 'Z', + 'Ř' => 'R', 'Ŕ' => 'R', + 'Ť' => 'T', 'Ţ' => 'T', + 'Ğ' => 'G', 'Ĝ' => 'G', 'Ġ' => 'G', 'Ģ' => 'G', + ]; + + /** + * Convert a Latin (visual-zone) name to its MRZ representation: + * uppercased, diacritics transliterated, spaces / hyphens / apostrophes + * turned into the '<' filler. + */ + public static function fromLatin(string $name): string + { + $name = mb_strtoupper(trim($name)); + $name = strtr($name, self::DIGRAPHS); + $name = strtr($name, self::DIACRITICS); + + // Word separators become '<'; anything still non A-Z is dropped. + $name = preg_replace('/[\s\-\'’.]+/u', '<', $name) ?? $name; + $name = preg_replace('/[^A-Z<]/u', '', $name) ?? $name; + + return trim($name, '<'); + } + + /** + * Compare a name taken from an MRZ against a visual-zone (Latin) name, + * ignoring filler and separators. Useful to verify an MRZ against typed + * input. + */ + public static function matches(string $mrzName, string $visualName): bool + { + return self::canonical($mrzName) === self::canonical(self::fromLatin($visualName)); + } + + private static function canonical(string $value): string + { + return trim(str_replace('<', ' ', strtoupper($value))); + } +} diff --git a/src/Traits/CountryMapper.php b/src/Traits/CountryMapper.php index 6aac2a4..828a376 100644 --- a/src/Traits/CountryMapper.php +++ b/src/Traits/CountryMapper.php @@ -1,5 +1,7 @@ |null */ - public function mapCountry(string $code = null): ?string + private ?array $countryMap = null; + + /** + * Map country name based on code. Unknown / filler codes return null. + */ + public function mapCountry(?string $code = null): ?string { - $allCountry = array_merge( + if ($code === null) { + return null; + } + + $this->countryMap ??= array_merge( $this->countries, $this->nonIso3166, $this->unitedNations, $this->britishNations ); - return (array_key_exists($code, $allCountry)) ? $allCountry[$code] : null; + return $this->countryMap[$code] ?? null; + } + + /** + * Return the raw ISO 3166 alpha-3 code if it maps to a known country, + * otherwise null. Useful when the caller needs the code, not the name. + */ + public function mapCountryCode(?string $code = null): ?string + { + return $this->mapCountry($code) === null ? null : $code; } } diff --git a/src/Traits/DateFormatter.php b/src/Traits/DateFormatter.php index aadc1b9..a6e011f 100644 --- a/src/Traits/DateFormatter.php +++ b/src/Traits/DateFormatter.php @@ -1,5 +1,7 @@ validateDateFormat($date)) { - $dateTime = DateTime::createFromFormat('ymd', $date); + if (! $this->validateDateFormat($date)) { + return null; + } - return $dateTime->format($format); + $dateTime = DateTime::createFromFormat('!ymd', $date); + $errors = DateTime::getLastErrors(); + + if ($dateTime === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) { + return null; } - return null; + if ($isPast && $dateTime > new DateTime('today')) { + $dateTime->modify('-100 years'); + } + + return $dateTime->format($format); } /** - * Validate Date Format YYMMDD - * MM range must be 01-12 - * DD range must be 01-31 - * - * @param string $date - * @return bool + * Cheap pre-filter for the YYMMDD shape. + * MM range must be 01-12, DD range must be 01-31. + * Calendar-impossible dates (Feb 30, Feb 29 on non-leap years) are + * rejected later via DateTime::getLastErrors(). */ public function validateDateFormat(string $date): bool { + if (strlen($date) !== 6 || ! ctype_digit($date)) { + return false; + } + $month = (int) substr($date, 2, 2); - $date = (int) substr($date, 4, 2); + $day = (int) substr($date, 4, 2); - return $month >= 1 && $month <= 12 && $date >= 1 && $date <= 31; + return $month >= 1 && $month <= 12 && $day >= 1 && $day <= 31; } } diff --git a/src/Traits/GenderMapper.php b/src/Traits/GenderMapper.php index ad554c0..a0ef94e 100644 --- a/src/Traits/GenderMapper.php +++ b/src/Traits/GenderMapper.php @@ -1,21 +1,20 @@ "Male", - "F" => "Female", - default => null + 'M' => 'Male', + 'F' => 'Female', + default => null, }; } } diff --git a/src/Traits/NameFormatter.php b/src/Traits/NameFormatter.php new file mode 100644 index 0000000..2e6efb4 --- /dev/null +++ b/src/Traits/NameFormatter.php @@ -0,0 +1,37 @@ + + */ + protected array $nameString = []; + + /** + * Given name(s): the segment after '<<', with the single '<' separator + * turned into spaces and trailing filler removed. + */ + protected function firstNameFromNameString(): ?string + { + $given = $this->nameString[1] ?? ''; + $given = trim(str_replace('<', ' ', rtrim($given, '<'))); + + return $given === '' ? null : $given; + } + + /** + * Surname: the segment before '<<', trailing filler removed. + */ + protected function lastNameFromNameString(): ?string + { + $last = rtrim($this->nameString[0] ?? '', '<'); + + return $last === '' ? null : $last; + } +} diff --git a/src/ValidateDocument.php b/src/ValidateDocument.php index 82bf634..38e66c1 100644 --- a/src/ValidateDocument.php +++ b/src/ValidateDocument.php @@ -1,30 +1,33 @@ text = $text; + $this->text = MrzNormalizer::normalize($text ?? ''); } - protected function setProperties() + protected function setProperties(): self { - $rows = explode("\n", $this->text); + $rows = MrzNormalizer::rows($this->text); $this->rows = count($rows); $this->characterCountOfRow = isset($rows[0]) ? strlen($rows[0]) : 0; $this->firstCharacter = substr($this->text, 0, 1); @@ -38,7 +41,7 @@ protected function setProperties() */ protected function isPassport(): bool { - if ($this->rows == 2 && $this->firstCharacter == 'P' && $this->characterCountOfRow == 44) { + if ($this->rows === 2 && $this->firstCharacter === 'P' && $this->characterCountOfRow === 44) { $this->documentType = DocumentType::PASSPORT; return true; @@ -53,7 +56,7 @@ protected function isPassport(): bool */ protected function isVisa(): bool { - if ($this->rows == 2 && $this->firstCharacter == 'V' && in_array($this->characterCountOfRow, [44, 36])) { + if ($this->rows === 2 && $this->firstCharacter === 'V' && in_array($this->characterCountOfRow, [44, 36], true)) { $this->documentType = DocumentType::VISA; return true; @@ -68,7 +71,7 @@ protected function isVisa(): bool */ protected function isTravelDocument1(): bool { - if ($this->rows == 3 && in_array($this->firstCharacter, ["I", "A", "C"]) && $this->characterCountOfRow == 30) { + if ($this->rows === 3 && in_array($this->firstCharacter, ['I', 'A', 'C'], true) && $this->characterCountOfRow === 30) { $this->documentType = DocumentType::TRAVEL_DOCUMENT_1; return true; @@ -83,7 +86,7 @@ protected function isTravelDocument1(): bool */ protected function isTravelDocument2(): bool { - if ($this->rows == 2 && in_array($this->firstCharacter, ["I", "P", "A", "C"]) && $this->characterCountOfRow == 36) { + if ($this->rows === 2 && in_array($this->firstCharacter, ['I', 'P', 'A', 'C'], true) && $this->characterCountOfRow === 36) { $this->documentType = DocumentType::TRAVEL_DOCUMENT_2; return true; @@ -92,26 +95,47 @@ protected function isTravelDocument2(): bool return false; } + /** + * French national ID (legacy Carte Nationale d'Identité) + * 2 rows, 36 characters each, first line starts with 'IDFRA'. + * + * Checked before TD2 because it also starts with 'I' and is 2x36, but has + * a bespoke field layout. + */ + protected function isFrenchId(): bool + { + if ($this->rows === 2 && $this->characterCountOfRow === 36 && str_starts_with($this->text, 'IDFRA')) { + $this->documentType = DocumentType::FRENCH_ID; + + return true; + } + + return false; + } + /** * Validate Document Based on Structure * * Reference: https://en.wikipedia.org/wiki/Machine-readable_passport - * */ - protected function isValid() + protected function isValid(): bool { - return $this->isPassport() || $this->isVisa() || $this->isTravelDocument1() || $this->isTravelDocument2(); + return $this->isPassport() + || $this->isVisa() + || $this->isTravelDocument1() + || $this->isFrenchId() + || $this->isTravelDocument2(); } /** * Validate Machine Readable Zone from Document */ - public function validate(): mixed + public function validate(): DocumentType { $this->setProperties(); if (! $this->isValid()) { - throw new InvalidFormatException("The given input format is invalid!"); + throw new InvalidFormatException('The given input format is invalid!'); } return $this->documentType; diff --git a/tests/CheckDigitTest.php b/tests/CheckDigitTest.php new file mode 100644 index 0000000..0c3de1a --- /dev/null +++ b/tests/CheckDigitTest.php @@ -0,0 +1,33 @@ +toBe($expected); +})->with([ + 'document number' => ['L898902C3', 6], + 'date of birth' => ['740812', 2], + 'date of expiry' => ['120415', 9], + 'letters and filler' => ['ZE184226B<<<<<', 1], +]); + +it('verifies a field against its check digit', function () { + expect(CheckDigit::verify('740812', '2'))->toBeTrue() + ->and(CheckDigit::verify('740812', '3'))->toBeFalse(); +}); + +it('accepts a filler check digit only for a filler field', function () { + expect(CheckDigit::verify('<<<<<<', '<'))->toBeTrue() + ->and(CheckDigit::verify('L898902C3', '<'))->toBeFalse(); +}); + +it('rejects a non-digit expected check digit', function () { + expect(CheckDigit::verify('740812', 'X'))->toBeFalse(); +}); + +it('treats unexpected characters as zero-weight', function () { + // The trailing symbol carries no value, so it does not change the sum. + expect(CheckDigit::calculate('1!'))->toBe(CheckDigit::calculate('1')); +}); diff --git a/tests/DateFormatterTest.php b/tests/DateFormatterTest.php new file mode 100644 index 0000000..627300c --- /dev/null +++ b/tests/DateFormatterTest.php @@ -0,0 +1,41 @@ +formatDate('740812'))->toBe('1974-08-12'); +}); + +it('rejects impossible calendar dates', function (string $date) use ($formatter) { + expect($formatter->formatDate($date))->toBeNull(); +})->with([ + 'month 13' => ['741312'], + 'day 32' => ['740832'], + 'feb 30' => ['110230'], + 'feb 29 non-leap' => ['110229'], + 'filler' => ['<<<<<<'], + 'short' => ['7408'], + 'non-digit' => ['74O812'], +]); + +it('shifts a future date of birth back a century', function () use ($formatter) { + // '491231' naively parses to 2049 (a future year); as a date of birth + // it must be shifted back to 1949. + expect($formatter->formatDate('491231', 'Y-m-d', true))->toBe('1949-12-31'); +}); + +it('keeps expiry dates on the literal century pivot', function () use ($formatter) { + expect($formatter->formatDate('491231'))->toBe('2049-12-31'); +}); + +it('validates the YYMMDD shape', function () use ($formatter) { + expect($formatter->validateDateFormat('740812'))->toBeTrue() + ->and($formatter->validateDateFormat('741312'))->toBeFalse() + ->and($formatter->validateDateFormat('<<<<<<'))->toBeFalse(); +}); diff --git a/tests/DocumentTypeLabelTest.php b/tests/DocumentTypeLabelTest.php new file mode 100644 index 0000000..9765fff --- /dev/null +++ b/tests/DocumentTypeLabelTest.php @@ -0,0 +1,36 @@ +toBe($label); +})->with([ + 'crew' => ['AC', 'Crew Member Certificates'], + 'national id' => ['I<', 'National ID'], + 'passport card' => ['IP', 'Passport'], + 'default' => ['C<', 'Travel Document (TD1)'], +]); + +it('labels TD2 documents from the first two characters', function (string $prefix, string $label) { + expect(MrzParser::parse(td2WithPrefix($prefix))['type'])->toBe($label); +})->with([ + 'crew' => ['AC', 'Crew Member Certificates'], + 'national id' => ['I<', 'National ID'], + 'passport card' => ['IP', 'Passport'], + 'default' => ['C<', 'Travel Document (TD2)'], +]); diff --git a/tests/EntryPointsTest.php b/tests/EntryPointsTest.php new file mode 100644 index 0000000..f6c0d1f --- /dev/null +++ b/tests/EntryPointsTest.php @@ -0,0 +1,34 @@ +toBeArray() + ->and(MrzParser::tryParse('not an mrz'))->toBeNull() + ->and(MrzParser::tryParse(''))->toBeNull(); +}); + +it('tryRead returns a result or null', function () { + expect(MrzParser::tryRead(MRZ_PASSPORT))->toBeInstanceOf(MrzResult::class) + ->and(MrzParser::tryRead('garbage'))->toBeNull(); +}); + +it('strict mode throws on a failed check digit', function () { + MrzParser::parse(MRZ_BAD_CHECKSUM, strict: true); +})->throws(InvalidChecksumException::class); + +it('strict mode passes a valid document', function () { + expect(MrzParser::parse(MRZ_PASSPORT, strict: true)['valid'])->toBeTrue(); +}); + +it('strict tryParse swallows the checksum failure', function () { + expect(MrzParser::tryParse(MRZ_BAD_CHECKSUM, strict: true))->toBeNull() + ->and(MrzParser::tryParse(MRZ_BAD_CHECKSUM))->toBeArray(); // non-strict still parses +}); diff --git a/tests/ExampleTest.php b/tests/ExampleTest.php deleted file mode 100644 index 5d36321..0000000 --- a/tests/ExampleTest.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); diff --git a/tests/FrenchIdTest.php b/tests/FrenchIdTest.php new file mode 100644 index 0000000..1c6b6fc --- /dev/null +++ b/tests/FrenchIdTest.php @@ -0,0 +1,51 @@ +validate())->toBe(DocumentType::FRENCH_ID); +}); + +it('parses a French national ID', function () { + expect(MrzParser::parse(MRZ_FRENCH_ID))->toMatchArray([ + 'type' => 'National ID (France)', + 'card_no' => '050693202043', + 'issuer' => 'France', + 'issuer_code' => 'FRA', + 'date_of_expiry' => null, + 'first_name' => 'CHRISTIANE NI', + 'last_name' => 'DOUEL', + 'date_of_birth' => '1929-06-20', + 'gender' => 'Female', + 'sex' => 'F', + 'nationality' => 'France', + 'valid' => true, + ]); +}); + +it('validates all French ID check digits', function () { + expect(MrzParser::parse(MRZ_FRENCH_ID)['validation'])->toBe([ + 'card_no' => true, + 'date_of_birth' => true, + 'composite' => true, + ]); +}); + +it('flags a tampered French ID composite check digit', function () { + // Flip the final composite check digit from 3 to 4. + $mrz = "IDFRADOUEL<<<<<<<<<<<<<<<<<<<<932013\n" + . '0506932020438CHRISTIANE<toBeFalse() + ->and($data['valid'])->toBeFalse(); +}); + +it('reports no expiry for a French ID', function () { + expect(MrzParser::read(MRZ_FRENCH_ID)->isExpired())->toBeNull(); +}); diff --git a/tests/Laravel/ValidMrzTest.php b/tests/Laravel/ValidMrzTest.php new file mode 100644 index 0000000..e3f75a4 --- /dev/null +++ b/tests/Laravel/ValidMrzTest.php @@ -0,0 +1,38 @@ +validate('mrz', $value, function (string $error) use (&$message) { + $message = $error; + }); + + return $message; +} + +it('passes for a valid MRZ', function () { + expect(runRule(new ValidMrz(), MRZ_PASSPORT))->toBeNull(); +}); + +it('fails for non-MRZ input', function (mixed $value) { + expect(runRule(new ValidMrz(), $value))->not->toBeNull(); +})->with([ + 'garbage' => ['not an mrz'], + 'empty' => [''], + 'non-string' => [12345], +]); + +it('fails a bad check digit only in strict mode', function () { + $bad = "PtoBeNull() + ->and(runRule(new ValidMrz(strict: true), $bad))->not->toBeNull(); +}); diff --git a/tests/MapperTest.php b/tests/MapperTest.php new file mode 100644 index 0000000..659a321 --- /dev/null +++ b/tests/MapperTest.php @@ -0,0 +1,42 @@ +mapGender($code))->toBe($expected); +})->with([ + 'male' => ['M', 'Male'], + 'female' => ['F', 'Female'], + 'filler' => ['<', null], + 'unknown' => ['X', null], + 'null' => [null, null], +]); + +it('maps known country codes to names', function () use ($countries) { + expect($countries->mapCountry('USA'))->toBe('United States of America (the)') + ->and($countries->mapCountry('UTO'))->toBe('Utopian'); +}); + +it('returns null for unknown or filler country codes', function (?string $code) use ($countries) { + expect($countries->mapCountry($code))->toBeNull(); +})->with([ + 'unknown' => ['ZZZ'], + 'filler' => ['<<<'], + 'null' => [null], +]); + +it('returns the raw code only when it is known', function () use ($countries) { + expect($countries->mapCountryCode('UTO'))->toBe('UTO') + ->and($countries->mapCountryCode('ZZZ'))->toBeNull(); +}); diff --git a/tests/MrzParserTest.php b/tests/MrzParserTest.php new file mode 100644 index 0000000..0880b4e --- /dev/null +++ b/tests/MrzParserTest.php @@ -0,0 +1,121 @@ +toMatchArray([ + 'type' => 'Passport', + 'card_no' => 'L898902C3', + 'issuer' => 'Utopian', + 'issuer_code' => 'UTO', + 'date_of_expiry' => '2012-04-15', + 'first_name' => 'ANNA MARIA', + 'last_name' => 'ERIKSSON', + 'date_of_birth' => '1974-08-12', + 'gender' => 'Female', + 'sex' => 'F', + 'personal_number' => 'ZE184226B', + 'nationality' => 'Utopian', + 'nationality_code' => 'UTO', + 'valid' => true, + ]); +}); + +it('parses a 44-character visa', function () { + expect(MrzParser::parse(MRZ_VISA_44))->toMatchArray([ + 'type' => 'Visa', + 'card_no' => 'L8988901C', + 'issuer' => 'Utopian', + 'first_name' => 'ANNA MARIA', + 'last_name' => 'ERIKSSON', + 'date_of_birth' => '1974-08-12', + 'gender' => 'Female', + 'valid' => true, + ]); +}); + +it('parses a 36-character visa', function () { + $data = MrzParser::parse(MRZ_VISA_36); + + expect($data['type'])->toBe('Visa') + ->and($data['card_no'])->toBe('L8988901C') + ->and($data['last_name'])->toBe('ERIKSSON') + ->and($data['valid'])->toBeTrue(); +}); + +it('parses a TD1 travel document', function () { + expect(MrzParser::parse(MRZ_TD1))->toMatchArray([ + 'type' => 'National ID', + 'card_no' => 'D23145890', + 'issuer' => 'Utopian', + 'date_of_expiry' => '2012-04-15', + 'first_name' => 'ANNA MARIA', + 'last_name' => 'ERIKSSON', + 'date_of_birth' => '1974-08-12', + 'gender' => 'Female', + 'nationality' => 'Utopian', + 'valid' => true, + ]); +}); + +it('parses a TD2 travel document', function () { + expect(MrzParser::parse(MRZ_TD2))->toMatchArray([ + 'type' => 'National ID', + 'card_no' => 'D23145890', + 'issuer' => 'Utopian', + 'date_of_expiry' => '2012-04-15', + 'first_name' => 'ANNA MARIA', + 'last_name' => 'ERIKSSON', + 'date_of_birth' => '1974-08-12', + 'gender' => 'Female', + 'nationality' => 'Utopian', + 'valid' => true, + ]); +}); + +it('exposes per-field check-digit validation', function () { + $data = MrzParser::parse(MRZ_PASSPORT); + + expect($data['validation'])->toBe([ + 'card_no' => true, + 'date_of_birth' => true, + 'date_of_expiry' => true, + 'personal_number' => true, + 'composite' => true, + ]); +}); + +it('flags an invalid document-number check digit', function () { + // Change the document number check digit (index 9) from 6 to 5. + $mrz = "PtoBeFalse() + ->and($data['valid'])->toBeFalse(); +}); + +it('normalizes CRLF and surrounding whitespace', function () { + $crlf = " PtoEqual(MrzParser::parse(MRZ_PASSPORT)); +}); + +it('echoes the normalized raw MRZ', function () { + expect(MrzParser::parse(MRZ_PASSPORT)['raw'])->toBe(MRZ_PASSPORT); +}); + +it('throws on invalid formats', function (string $input) { + MrzParser::parse($input); +})->throws(InvalidFormatException::class)->with([ + 'empty' => [''], + 'single row' => ['P ["P ["P ["XtoBeInstanceOf(MrzResult::class) + ->and($result->type)->toBe('Passport') + ->and($result->cardNo)->toBe('L898902C3') + ->and($result->nationalityCode)->toBe('UTO') + ->and($result->valid)->toBeTrue() + ->and($result->fullName())->toBe('ANNA MARIA ERIKSSON'); +}); + +it('preserves the canonical array via toArray()', function () { + expect(MrzParser::read(MRZ_PASSPORT)->toArray())->toBe(MrzParser::parse(MRZ_PASSPORT)); +}); + +it('exposes dates as DateTimeImmutable', function () { + $result = MrzParser::read(MRZ_PASSPORT); + + expect($result->dateOfBirthAsDate()->format('Y-m-d'))->toBe('1974-08-12') + ->and($result->dateOfExpiryAsDate()->format('Y-m-d'))->toBe('2012-04-15'); +}); + +it('reports expiry relative to a reference date', function () { + $result = MrzParser::read(MRZ_PASSPORT); // expires 2012-04-15 + + expect($result->isExpired(new DateTimeImmutable('2011-01-01')))->toBeFalse() + ->and($result->isExpired(new DateTimeImmutable('2020-01-01')))->toBeTrue(); +}); + +it('returns null expiry status when no expiry date is present', function () { + $result = MrzResult::fromArray(['type' => 'X', 'date_of_expiry' => null]); + + expect($result->isExpired())->toBeNull() + ->and($result->dateOfExpiryAsDate())->toBeNull(); +}); diff --git a/tests/NameTest.php b/tests/NameTest.php new file mode 100644 index 0000000..31db3d5 --- /dev/null +++ b/tests/NameTest.php @@ -0,0 +1,26 @@ +toBe('ERIKSSON') + ->and($data['first_name'])->toBeNull(); +}); + +it('joins multi-part given names with spaces (TD1)', function () { + $line1 = 'ItoBe('WILLIAMS') + ->and($data['first_name'])->toBe('JOHN PAUL'); +}); diff --git a/tests/Pest.php b/tests/Pest.php index b3d9bbc..fd0ea5a 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1 +1,29 @@ toBe($expected); +})->with([ + 'german umlaut' => ['Müller', 'MUELLER'], + 'eszett' => ['Weiß', 'WEISS'], + 'slashed o' => ['Jørgensen', 'JOERGENSEN'], + 'ae ligature' => ['Æther', 'AETHER'], + 'accents folded' => ['José', 'JOSE'], + 'spaces to filler' => ['Anna Maria', 'ANNA ['Jean-Luc', 'JEAN ["O'Brien", 'OtoBeTrue() + ->and(Transliterator::matches('ANNAtoBeTrue() + ->and(Transliterator::matches('SMITH', 'Jones'))->toBeFalse(); +}); diff --git a/tests/ValidateDocumentTest.php b/tests/ValidateDocumentTest.php new file mode 100644 index 0000000..9a3377f --- /dev/null +++ b/tests/ValidateDocumentTest.php @@ -0,0 +1,40 @@ +validate())->toBe($type); +})->with([ + 'passport' => [MRZ_PASSPORT, DocumentType::PASSPORT], + 'visa 44' => [MRZ_VISA_44, DocumentType::VISA], + 'visa 36' => [MRZ_VISA_36, DocumentType::VISA], + 'td1' => [MRZ_TD1, DocumentType::TRAVEL_DOCUMENT_1], + 'td2' => [MRZ_TD2, DocumentType::TRAVEL_DOCUMENT_2], +]); + +it('accepts every valid TD1 first character', function (string $first) { + $mrz = "{$first}validate())->toBe(DocumentType::TRAVEL_DOCUMENT_1); +})->with(['I', 'A', 'C']); + +it('accepts every valid TD2 first character', function (string $first) { + $mrz = "{$first}validate())->toBe(DocumentType::TRAVEL_DOCUMENT_2); +})->with(['I', 'P', 'A', 'C']); + +it('throws on structurally invalid input', function (string $mrz) { + (new ValidateDocument($mrz))->validate(); +})->throws(InvalidFormatException::class)->with([ + 'empty' => [''], + 'one row' => ['P ["PtoBe('6ZE184226B'); +}); + +it('reads the 8-character optional field on an MRV-B (36)', function () { + // The MRV-B sample carries only filler in its 8-char optional slot. + expect(MrzParser::parse(MRZ_VISA_36)['optional_data'])->toBeNull(); +});