Neuron®
The Neuron® is the basis for the creation of open and secure federated networks for smart societies.
Loading...
Searching...
No Matches
MrzValidator.cs
1using System;
3using System.Diagnostics.CodeAnalysis;
4using System.Linq;
5using System.Text;
6using System.Text.RegularExpressions;
7
9{
13 public enum MrzLayout
14 {
18 Td1 = 0,
19
23 Td2 = 1,
24
28 Td3 = 2
29 }
30
34 public sealed class MrzValidationResult
35 {
36 internal MrzValidationResult(
37 bool IsParsed,
38 MrzLayout? Layout,
39 string NormalizedMrz,
47 string? FailureReason)
48 {
49 this.IsParsed = IsParsed;
50 this.Layout = Layout;
51 this.NormalizedMrz = NormalizedMrz;
52 this.DocumentInformation = DocumentInformation;
53 this.DocumentNumberCheckPassed = DocumentNumberCheckPassed;
54 this.DateOfBirthCheckPassed = DateOfBirthCheckPassed;
55 this.ExpiryCheckPassed = ExpiryCheckPassed;
56 this.CompositeCheckPassed = CompositeCheckPassed;
57 this.RequiredCheckCount = RequiredCheckCount;
58 this.PassedRequiredCheckCount = PassedRequiredCheckCount;
59 this.FailureReason = FailureReason;
60 }
61
65 public bool IsParsed { get; }
66
70 public MrzLayout? Layout { get; }
71
75 public string NormalizedMrz { get; }
76
81
85 public bool DocumentNumberCheckPassed { get; }
86
90 public bool DateOfBirthCheckPassed { get; }
91
95 public bool ExpiryCheckPassed { get; }
96
100 public bool CompositeCheckPassed { get; }
101
105 public int RequiredCheckCount { get; }
106
113 public int PassedRequiredCheckCount { get; }
114
118 public string? FailureReason { get; }
119 }
120
124 public static class MrzValidator
125 {
126 private static readonly IReadOnlyDictionary<char, char> CheckDigitConfusions = new Dictionary<char, char>
127 {
128 { 'O', '0' },
129 { 'Q', '0' },
130 { 'I', '1' },
131 { 'L', '1' },
132 { 'Z', '2' },
133 { 'S', '5' },
134 { 'B', '8' },
135 { 'G', '6' },
136 { '<', '0' }
137 };
138
139 private static readonly Regex Td2MrzNumber9CharsPlus = new(@"^(?'DocType'.{1,2})<(?'Issuer'[A-Z<]{3})(?'PID'[^<]+(<[^<]+)*)<<(?'SID'[^<]+(<[^<]+)*)<*\n(?'Nr1'.{9})<(?'Nationality'[A-Z<]{3})(?'Birth'.{6})(?'BirthCheck'\d)(?'Gender'[MF<])(?'Expires'.{6})(?'ExpiryCheck'\d)(?'Tail'.{7})(?'OverallCheck'\d)$", RegexOptions.Multiline);
140 private static readonly Regex Td2MrzNumber9Chars = new(@"^(?'DocType'.{1,2})<(?'Issuer'[A-Z<]{3})(?'PID'[^<]+(<[^<]+)*)<<(?'SID'[^<]+(<[^<]+)*)<*\n(?'Nr'.{9})(?'NrCheck'\d)(?'Nationality'[A-Z<]{3})(?'Birth'.{6})(?'BirthCheck'\d)(?'Gender'[MF<])(?'Expires'.{6})(?'ExpiryCheck'\d)(?'Optional'.{7})(?'OverallCheck'\d)$", RegexOptions.Multiline);
141 private static readonly Regex Td1MrzNumber9CharsPlus = new(@"^(?'DocType'.{1,2})<(?'Issuer'[A-Z<]{3})(?'Nr1'.{9})<(?'Nr2'.{3})(?'NrCheck'\d)(?'Optional1'.{11})\n(?'Birth'.{6})(?'BirthCheck'\d)(?'Gender'[MF<])(?'Expires'.{6})(?'ExpiryCheck'\d)(?'Nationality'[A-Z<]{3})(?'Optional2'.{11})(?'OverallCheck'\d)\n(?'PID'[^<]+(<[^<]+)*)<<(?'SID'[^<]+(<[^<]+)*).*$", RegexOptions.Multiline);
142 private static readonly Regex Td1MrzNumber9Chars = new(@"^(?'DocType'.{1,2})<(?'Issuer'[A-Z<]{3})(?'Nr'.{9})(?'NrCheck'.)(?'Optional1'.{15})\n(?'Birth'[^<]{6})(?'BirthCheck'\d)(?'Gender'[MF<])(?'Expires'[^<]{6})(?'ExpiryCheck'\d)(?'Nationality'[A-Z<]{3})(?'Optional2'.{11})(?'OverallCheck'\d)\n(?'PID'[^<]+(<[^<]+)*)<<(?'SID'[^<]+(<[^<]+)*).*$", RegexOptions.Multiline);
143 private static readonly Regex Td3MrzNumber9Chars = new(@"^(?'DocType'.{1,2})<(?'Issuer'[A-Z<]{3})(?'PID'[^<]+(<[^<]+)*)<<(?'SID'[^<]+(<[^<]+)*)<*\n(?'Nr'.{9})(?'NrCheck'\d)(?'Nationality'[A-Z<]{3})(?'Birth'[^<]{6})(?'BirthCheck'\d)(?'Gender'[MF<])(?'Expires'[^<]{6})(?'ExpiryCheck'\d)(?'Optional'.{14})(?'OptionalCheck'\d)(?'OverallCheck'\d)$", RegexOptions.Multiline);
144
145 private static readonly ParserDefinition[] OrderedPatterns =
146 [
147 new ParserDefinition(MrzLayout.Td2, Td2MrzNumber9CharsPlus, AssembleTd2Info2),
148 new ParserDefinition(MrzLayout.Td2, Td2MrzNumber9Chars, AssembleTd2Info1),
149 new ParserDefinition(MrzLayout.Td3, Td3MrzNumber9Chars, AssembleTd3Info1),
150 new ParserDefinition(MrzLayout.Td1, Td1MrzNumber9CharsPlus, AssembleTd1Info2),
151 new ParserDefinition(MrzLayout.Td1, Td1MrzNumber9Chars, AssembleTd1Info1)
152 ];
153
160 public static bool TryParse(string Mrz, [NotNullWhen(true)] out DocumentInformation? Info)
161 {
162 MrzValidationResult Validation = Validate(Mrz);
163 Info = Validation.DocumentInformation;
164 return Validation.IsParsed;
165 }
166
172 public static MrzValidationResult Validate(string Mrz)
173 {
174 ArgumentNullException.ThrowIfNull(Mrz);
175
176 string NormalizedMrz = NormalizeMrz(Mrz);
177 string[] Lines = SplitLines(NormalizedMrz);
178 MrzLayout? Layout = TryDetectLayout(Lines);
179 int RequiredCheckCount = Layout.HasValue ? GetRequiredCheckCount(Layout.Value) : 0;
180 int PassedRequiredCheckCount = 0;
181 bool DocumentNumberCheckPassed = false;
182 bool DateOfBirthCheckPassed = false;
183 bool ExpiryCheckPassed = false;
184 bool CompositeCheckPassed = false;
185
186 if (Layout.HasValue)
187 {
188 PassedRequiredCheckCount = CountPassedRequiredChecks(
189 Layout.Value,
190 Lines,
191 out DocumentNumberCheckPassed,
192 out DateOfBirthCheckPassed,
193 out ExpiryCheckPassed,
194 out CompositeCheckPassed);
195 }
196
197 DocumentInformation? ParsedInformation;
198 MrzLayout? ParsedLayout;
199 if (Layout.HasValue)
200 {
201 if (TryParseStandard(Layout.Value, Lines, out ParsedInformation)
202 && IsSemanticallyValid(Layout.Value, ParsedInformation))
203 {
204 return new MrzValidationResult(
205 true,
206 Layout,
207 NormalizedMrz,
208 ParsedInformation,
209 DocumentNumberCheckPassed,
210 DateOfBirthCheckPassed,
211 ExpiryCheckPassed,
212 CompositeCheckPassed,
213 RequiredCheckCount,
214 PassedRequiredCheckCount,
215 null);
216 }
217
218 if (TryParseFallback(NormalizedMrz, Layout, out ParsedInformation, out ParsedLayout)
219 && ParsedLayout.HasValue
220 && IsSemanticallyValid(ParsedLayout.Value, ParsedInformation))
221 {
222 return new MrzValidationResult(
223 true,
224 ParsedLayout,
225 NormalizedMrz,
226 ParsedInformation,
227 DocumentNumberCheckPassed,
228 DateOfBirthCheckPassed,
229 ExpiryCheckPassed,
230 CompositeCheckPassed,
231 RequiredCheckCount,
232 PassedRequiredCheckCount,
233 null);
234 }
235
236 return new MrzValidationResult(
237 false,
238 Layout,
239 NormalizedMrz,
240 null,
241 DocumentNumberCheckPassed,
242 DateOfBirthCheckPassed,
243 ExpiryCheckPassed,
244 CompositeCheckPassed,
245 RequiredCheckCount,
246 PassedRequiredCheckCount,
247 "StrictLayoutValidationFailed");
248 }
249
250 if (TryParseFallback(NormalizedMrz, null, out ParsedInformation, out ParsedLayout)
251 && ParsedLayout.HasValue
252 && IsSemanticallyValid(ParsedLayout.Value, ParsedInformation))
253 {
254 return new MrzValidationResult(
255 true,
256 ParsedLayout,
257 NormalizedMrz,
258 ParsedInformation,
259 DocumentNumberCheckPassed,
260 DateOfBirthCheckPassed,
261 ExpiryCheckPassed,
262 CompositeCheckPassed,
263 RequiredCheckCount,
264 PassedRequiredCheckCount,
265 null);
266 }
267
268 return new MrzValidationResult(
269 false,
270 Layout,
271 NormalizedMrz,
272 null,
273 DocumentNumberCheckPassed,
274 DateOfBirthCheckPassed,
275 ExpiryCheckPassed,
276 CompositeCheckPassed,
277 RequiredCheckCount,
278 PassedRequiredCheckCount,
279 Layout.HasValue ? "ValidationFailed" : "LayoutNotRecognized");
280 }
281
287 public static char CalculateCheckDigit(string Value)
288 {
289 string CheckDigit = CalculateCheckDigitText(Value);
290 return CheckDigit.Length == 1 ? CheckDigit[0] : '\0';
291 }
292
293 private static bool TryParseFallback(
294 string NormalizedMrz,
295 MrzLayout? ExpectedLayout,
296 [NotNullWhen(true)] out DocumentInformation? Info,
297 out MrzLayout? Layout)
298 {
299 foreach (ParserDefinition Definition in OrderedPatterns)
300 {
301 if (ExpectedLayout.HasValue && Definition.Layout != ExpectedLayout.Value)
302 continue;
303
304 Match Match = Definition.Pattern.Match(NormalizedMrz);
305 if (!Match.Success)
306 continue;
307
308 DocumentInformation? Candidate = Definition.Factory(Match);
309 if (Candidate is null)
310 continue;
311
312 Layout = Definition.Layout;
313 Info = Candidate;
314 return true;
315 }
316
317 Info = null;
318 Layout = null;
319 return false;
320 }
321
322 private static bool IsSemanticallyValid(MrzLayout Layout, DocumentInformation Info)
323 {
324 return Layout switch
325 {
326 MrzLayout.Td1 => IsSemanticallyValidTd1(Info),
327 MrzLayout.Td2 => IsSemanticallyValidTd2(Info),
328 MrzLayout.Td3 => IsSemanticallyValidTd3(Info),
329 _ => false
330 };
331 }
332
333 private static bool IsSemanticallyValidTd1(DocumentInformation Info)
334 {
335 return HasValidDocumentType(Info.DocumentType)
336 && HasValidCountryCode(Info.IssuingState)
337 && HasValidDocumentNumber(Info.DocumentNumber)
338 && HasValidMrzDate(Info.DateOfBirth)
339 && HasValidGender(Info.Gender)
340 && HasValidMrzDate(Info.ExpiryDate)
341 && HasValidCountryCode(Info.Nationality)
342 && HasValidIdentifiers(Info.PrimaryIdentifier, Info.SecondaryIdentifier)
343 && HasValidMrzInformation(Info.MRZ_Information);
344 }
345
346 private static bool IsSemanticallyValidTd2(DocumentInformation Info)
347 {
348 return HasValidDocumentType(Info.DocumentType)
349 && HasValidCountryCode(Info.IssuingState)
350 && HasValidDocumentNumber(Info.DocumentNumber)
351 && HasValidCountryCode(Info.Nationality)
352 && HasValidMrzDate(Info.DateOfBirth)
353 && HasValidGender(Info.Gender)
354 && HasValidMrzDate(Info.ExpiryDate)
355 && HasValidIdentifiers(Info.PrimaryIdentifier, Info.SecondaryIdentifier)
356 && HasValidMrzInformation(Info.MRZ_Information);
357 }
358
359 private static bool IsSemanticallyValidTd3(DocumentInformation Info)
360 {
361 return HasValidDocumentType(Info.DocumentType)
362 && HasValidCountryCode(Info.IssuingState)
363 && HasValidDocumentNumber(Info.DocumentNumber)
364 && HasValidCountryCode(Info.Nationality)
365 && HasValidMrzDate(Info.DateOfBirth)
366 && HasValidGender(Info.Gender)
367 && HasValidMrzDate(Info.ExpiryDate)
368 && HasValidIdentifiers(Info.PrimaryIdentifier, Info.SecondaryIdentifier)
369 && HasValidMrzInformation(Info.MRZ_Information);
370 }
371
372 private static bool HasValidDocumentType(string? Value)
373 {
374 return !string.IsNullOrWhiteSpace(Value)
375 && Value.Length <= 2
376 && Value.All(static Character => Character is >= 'A' and <= 'Z');
377 }
378
379 private static bool HasValidCountryCode(string? Value)
380 {
381 return !string.IsNullOrWhiteSpace(Value)
382 && Value.Length == 3
383 && Value.All(static Character => Character is >= 'A' and <= 'Z');
384 }
385
386 private static bool HasValidDocumentNumber(string? Value)
387 {
388 return !string.IsNullOrWhiteSpace(Value)
389 && Value.All(static Character => (Character is >= 'A' and <= 'Z') || (Character is >= '0' and <= '9'));
390 }
391
392 private static bool HasValidGender(string? Value)
393 {
394 return Value is "M" or "F" or "<";
395 }
396
397 private static bool HasValidMrzDate(string? Value)
398 {
399 if (string.IsNullOrWhiteSpace(Value) || Value.Length != 6 || !Value.All(static Character => Character is >= '0' and <= '9'))
400 return false;
401
402 if (Value == "000000")
403 return false;
404
405 int Month = int.Parse(Value.Substring(2, 2), System.Globalization.CultureInfo.InvariantCulture);
406 int Day = int.Parse(Value.Substring(4, 2), System.Globalization.CultureInfo.InvariantCulture);
407 if (Month < 1 || Month > 12)
408 return false;
409 if (Day < 1)
410 return false;
411
412 int MaxDay = Month switch
413 {
414 4 or 6 or 9 or 11 => 30,
415 2 => 29,
416 _ => 31
417 };
418
419 return Day <= MaxDay;
420 }
421
422 private static bool HasValidIdentifiers(string[]? PrimaryIdentifier, string[]? SecondaryIdentifier)
423 {
424 return HasValidIdentifierParts(PrimaryIdentifier, true)
425 && HasValidIdentifierParts(SecondaryIdentifier, false);
426 }
427
428 private static bool HasValidIdentifierParts(string[]? Parts, bool Required)
429 {
430 if (Parts is null || Parts.Length == 0)
431 return !Required;
432
433 bool HasContent = false;
434 foreach (string Part in Parts)
435 {
436 if (string.IsNullOrWhiteSpace(Part))
437 continue;
438
439 if (!Part.All(static Character => Character is >= 'A' and <= 'Z'))
440 return false;
441
442 HasContent = true;
443 }
444
445 return Required ? HasContent : true;
446 }
447
448 private static bool HasValidMrzInformation(string? Value)
449 {
450 return !string.IsNullOrWhiteSpace(Value)
451 && Value.All(static Character => (Character is >= 'A' and <= 'Z') || (Character is >= '0' and <= '9') || Character == '<');
452 }
453
454 private static bool TryParseStandard(
455 MrzLayout Layout,
456 string[] Lines,
457 [NotNullWhen(true)] out DocumentInformation? Info)
458 {
459 switch (Layout)
460 {
461 case MrzLayout.Td1:
462 return TryParseTd1Standard(Lines, out Info);
463
464 case MrzLayout.Td2:
465 return TryParseTd2Standard(Lines, out Info);
466
467 case MrzLayout.Td3:
468 return TryParseTd3Standard(Lines, out Info);
469
470 default:
471 Info = null;
472 return false;
473 }
474 }
475
476 private static bool TryParseTd1Standard(string[] Lines, [NotNullWhen(true)] out DocumentInformation? Info)
477 {
478 if (Lines.Length != 3 || Lines[0].Length != 30 || Lines[1].Length != 30 || Lines[2].Length != 30)
479 {
480 Info = null;
481 return false;
482 }
483
484 string Line1 = Lines[0];
485 string Line2 = Lines[1];
486 string Line3 = Lines[2];
487 if (Line1[14] == '<')
488 {
489 Info = null;
490 return false;
491 }
492
493 string DocumentNumber = Line1.Substring(5, 9);
494 if (!IsCheckDigitValid(DocumentNumber, Line1[14]))
495 {
496 Info = null;
497 return false;
498 }
499
500 string DateOfBirth = Line2.Substring(0, 6);
501 if (!IsCheckDigitValid(DateOfBirth, Line2[6]))
502 {
503 Info = null;
504 return false;
505 }
506
507 string ExpiryDate = Line2.Substring(8, 6);
508 if (!IsCheckDigitValid(ExpiryDate, Line2[14]))
509 {
510 Info = null;
511 return false;
512 }
513
514 string Composite = Line1.Substring(5, 10) + Line1.Substring(15, 15) + Line2.Substring(0, 7) + Line2.Substring(8, 7) + Line2.Substring(18, 11);
515 if (!IsCheckDigitValid(Composite, Line2[29]))
516 {
517 Info = null;
518 return false;
519 }
520
521 Info = new DocumentInformation
522 {
523 DocumentType = ExtractDocumentType(Line1.Substring(0, 2)),
524 IssuingState = Line1.Substring(2, 3),
525 DocumentNumber = DocumentNumber.Replace("<", string.Empty),
526 DateOfBirth = DateOfBirth,
527 Gender = Line2.Substring(7, 1),
528 ExpiryDate = ExpiryDate,
529 Nationality = Line2.Substring(15, 3),
530 OptionalData = (Line1.Substring(15, 15) + Line2.Substring(18, 11)).Replace("<", string.Empty),
531 MRZ_Information = DocumentNumber + Line1[14] + DateOfBirth + Line2[6] + ExpiryDate + Line2[14]
532 };
533 PopulateNames(Info, Line3);
534 return true;
535 }
536
537 private static bool TryParseTd2Standard(string[] Lines, [NotNullWhen(true)] out DocumentInformation? Info)
538 {
539 if (Lines.Length != 2 || Lines[0].Length != 36 || Lines[1].Length != 36)
540 {
541 Info = null;
542 return false;
543 }
544
545 string Line1 = Lines[0];
546 string Line2 = Lines[1];
547 string DocumentNumber = Line2.Substring(0, 9);
548 string DateOfBirth = Line2.Substring(13, 6);
549 string ExpiryDate = Line2.Substring(21, 6);
550 if (!IsCheckDigitValid(DocumentNumber, Line2[9])
551 || !IsCheckDigitValid(DateOfBirth, Line2[19])
552 || !IsCheckDigitValid(ExpiryDate, Line2[27]))
553 {
554 Info = null;
555 return false;
556 }
557
558 string Composite = Line2.Substring(0, 10) + Line2.Substring(13, 7) + Line2.Substring(21, 7) + Line2.Substring(28, 7);
559 if (!IsCheckDigitValid(Composite, Line2[35]))
560 {
561 Info = null;
562 return false;
563 }
564
565 Info = new DocumentInformation
566 {
567 DocumentType = ExtractDocumentType(Line1.Substring(0, 2)),
568 IssuingState = Line1.Substring(2, 3),
569 DocumentNumber = DocumentNumber.Replace("<", string.Empty),
570 Nationality = Line2.Substring(10, 3),
571 DateOfBirth = DateOfBirth,
572 Gender = Line2.Substring(20, 1),
573 ExpiryDate = ExpiryDate,
574 OptionalData = Line2.Substring(28, 7).Replace("<", string.Empty),
575 MRZ_Information = DocumentNumber + Line2[9] + DateOfBirth + Line2[19] + ExpiryDate + Line2[27]
576 };
577 PopulateNames(Info, Line1.Substring(5));
578 return true;
579 }
580
581 private static bool TryParseTd3Standard(string[] Lines, [NotNullWhen(true)] out DocumentInformation? Info)
582 {
583 if (Lines.Length != 2 || Lines[0].Length != 44 || Lines[1].Length != 44)
584 {
585 Info = null;
586 return false;
587 }
588
589 string Line1 = Lines[0];
590 string Line2 = Lines[1];
591 string DocumentNumber = Line2.Substring(0, 9);
592 string DateOfBirth = Line2.Substring(13, 6);
593 string ExpiryDate = Line2.Substring(21, 6);
594 string OptionalData = Line2.Substring(28, 14);
595 if (!IsCheckDigitValid(DocumentNumber, Line2[9])
596 || !IsCheckDigitValid(DateOfBirth, Line2[19])
597 || !IsCheckDigitValid(ExpiryDate, Line2[27])
598 || !IsCheckDigitValid(OptionalData, Line2[42]))
599 {
600 Info = null;
601 return false;
602 }
603
604 string Composite = Line2.Substring(0, 10) + Line2.Substring(13, 7) + Line2.Substring(21, 7) + Line2.Substring(28, 15);
605 if (!IsCheckDigitValid(Composite, Line2[43]))
606 {
607 Info = null;
608 return false;
609 }
610
611 Info = new DocumentInformation
612 {
613 DocumentType = ExtractDocumentType(Line1.Substring(0, 2)),
614 IssuingState = Line1.Substring(2, 3),
615 DocumentNumber = DocumentNumber.Replace("<", string.Empty),
616 Nationality = Line2.Substring(10, 3),
617 DateOfBirth = DateOfBirth,
618 Gender = Line2.Substring(20, 1),
619 ExpiryDate = ExpiryDate,
620 OptionalData = OptionalData.Replace("<", string.Empty),
621 MRZ_Information = DocumentNumber + Line2[9] + DateOfBirth + Line2[19] + ExpiryDate + Line2[27]
622 };
623 PopulateNames(Info, Line1.Substring(5));
624 return true;
625 }
626
627 private static int CountPassedRequiredChecks(
628 MrzLayout Layout,
629 string[] Lines,
630 out bool DocumentNumberCheckPassed,
631 out bool DateOfBirthCheckPassed,
632 out bool ExpiryCheckPassed,
633 out bool CompositeCheckPassed)
634 {
635 switch (Layout)
636 {
637 case MrzLayout.Td1:
638 DocumentNumberCheckPassed = Lines.Length >= 1 && Lines[0].Length >= 15 && IsCheckDigitValid(Lines[0].Substring(5, 9), Lines[0][14]);
639 DateOfBirthCheckPassed = Lines.Length >= 2 && Lines[1].Length >= 7 && IsCheckDigitValid(Lines[1].Substring(0, 6), Lines[1][6]);
640 ExpiryCheckPassed = Lines.Length >= 2 && Lines[1].Length >= 15 && IsCheckDigitValid(Lines[1].Substring(8, 6), Lines[1][14]);
641 CompositeCheckPassed = Lines.Length >= 2 && Lines[0].Length == 30 && Lines[1].Length == 30
642 && IsCheckDigitValid(Lines[0].Substring(5, 10) + Lines[0].Substring(15, 15) + Lines[1].Substring(0, 7) + Lines[1].Substring(8, 7) + Lines[1].Substring(18, 11), Lines[1][29]);
643 break;
644
645 case MrzLayout.Td2:
646 DocumentNumberCheckPassed = Lines.Length >= 2 && Lines[1].Length >= 10 && IsCheckDigitValid(Lines[1].Substring(0, 9), Lines[1][9]);
647 DateOfBirthCheckPassed = Lines.Length >= 2 && Lines[1].Length >= 20 && IsCheckDigitValid(Lines[1].Substring(13, 6), Lines[1][19]);
648 ExpiryCheckPassed = Lines.Length >= 2 && Lines[1].Length >= 28 && IsCheckDigitValid(Lines[1].Substring(21, 6), Lines[1][27]);
649 CompositeCheckPassed = Lines.Length >= 2 && Lines[1].Length == 36
650 && IsCheckDigitValid(Lines[1].Substring(0, 10) + Lines[1].Substring(13, 7) + Lines[1].Substring(21, 7) + Lines[1].Substring(28, 7), Lines[1][35]);
651 break;
652
653 default:
654 DocumentNumberCheckPassed = Lines.Length >= 2 && Lines[1].Length >= 10 && IsCheckDigitValid(Lines[1].Substring(0, 9), Lines[1][9]);
655 DateOfBirthCheckPassed = Lines.Length >= 2 && Lines[1].Length >= 20 && IsCheckDigitValid(Lines[1].Substring(13, 6), Lines[1][19]);
656 ExpiryCheckPassed = Lines.Length >= 2 && Lines[1].Length >= 28 && IsCheckDigitValid(Lines[1].Substring(21, 6), Lines[1][27]);
657 CompositeCheckPassed = Lines.Length >= 2 && Lines[1].Length == 44
658 && IsCheckDigitValid(Lines[1].Substring(0, 10) + Lines[1].Substring(13, 7) + Lines[1].Substring(21, 7) + Lines[1].Substring(28, 15), Lines[1][43]);
659 break;
660 }
661
662 int Count = 0;
663 if (DocumentNumberCheckPassed)
664 Count++;
665 if (DateOfBirthCheckPassed)
666 Count++;
667 if (ExpiryCheckPassed)
668 Count++;
669 if (CompositeCheckPassed)
670 Count++;
671 return Count;
672 }
673
674 private static int GetRequiredCheckCount(MrzLayout Layout)
675 {
676 return Layout switch
677 {
678 MrzLayout.Td1 => 4,
679 MrzLayout.Td2 => 4,
680 MrzLayout.Td3 => 4,
681 _ => 0
682 };
683 }
684
685 private static MrzLayout? TryDetectLayout(string[] Lines)
686 {
687 if (Lines.Length == 3 && Lines.All(static Line => Line.Length == 30))
688 return MrzLayout.Td1;
689 if (Lines.Length == 2 && Lines.All(static Line => Line.Length == 36))
690 return MrzLayout.Td2;
691 if (Lines.Length == 2 && Lines.All(static Line => Line.Length == 44))
692 return MrzLayout.Td3;
693 return null;
694 }
695
696 private static string NormalizeMrz(string Mrz)
697 {
698 StringBuilder Builder = new StringBuilder(Mrz.Length);
699 foreach (char Character in Mrz.ToUpperInvariant())
700 {
701 if (Character == '\r')
702 continue;
703 if (Character == '\n')
704 {
705 Builder.Append('\n');
706 continue;
707 }
708
709 Builder.Append(NormalizeMrzCharacter(Character));
710 }
711
712 string[] RawLines = Builder.ToString()
713 .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
714 if (RawLines.Length == 1)
715 {
716 string Flat = RawLines[0];
717 switch (Flat.Length)
718 {
719 case 90:
720 return string.Join("\n", [Flat.Substring(0, 30), Flat.Substring(30, 30), Flat.Substring(60, 30)]);
721
722 case 72:
723 return string.Join("\n", [Flat.Substring(0, 36), Flat.Substring(36, 36)]);
724
725 case 88:
726 return string.Join("\n", [Flat.Substring(0, 44), Flat.Substring(44, 44)]);
727 }
728 }
729
730 return string.Join("\n", RawLines);
731 }
732
733 private static string[] SplitLines(string NormalizedMrz)
734 {
735 if (string.IsNullOrWhiteSpace(NormalizedMrz))
736 return [];
737
738 return NormalizedMrz.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
739 }
740
741 private static char NormalizeMrzCharacter(char Character)
742 {
743 if (char.IsWhiteSpace(Character))
744 return '<';
745 if (Character == '<' || Character == '«' || Character == '‹' || Character == '›')
746 return '<';
747 if ((Character >= 'A' && Character <= 'Z') || (Character >= '0' && Character <= '9'))
748 return Character;
749 return Character;
750 }
751
752 private static bool IsCheckDigitValid(string Field, char ExpectedCheckDigit)
753 {
754 return NormalizeCheckDigitChar(ExpectedCheckDigit) == CalculateCheckDigit(Field);
755 }
756
757 private static char NormalizeCheckDigitChar(char Character)
758 {
759 char Normalized = NormalizeMrzCharacter(char.ToUpperInvariant(Character));
760 if (Normalized >= '0' && Normalized <= '9')
761 return Normalized;
762 return CheckDigitConfusions.TryGetValue(Normalized, out char Replacement) ? Replacement : '\0';
763 }
764
765 private static string CalculateCheckDigitText(string Value)
766 {
767 int Sum = 0;
768 int Index = 0;
769 foreach (char Character in Value)
770 {
771 int CharacterValue = GetMrzCharacterValue(Character);
772 if (CharacterValue < 0)
773 return string.Empty;
774
775 Sum += CharacterValue * Weights[Index++ % 3];
776 }
777
778 return new string((char)('0' + (Sum % 10)), 1);
779 }
780
781 private static int GetMrzCharacterValue(char Character)
782 {
783 if (Character >= '0' && Character <= '9')
784 return Character - '0';
785 if (Character >= 'A' && Character <= 'Z')
786 return (Character - 'A') + 10;
787 if (Character == '<')
788 return 0;
789 return -1;
790 }
791
792 private static string ExtractDocumentType(string Value)
793 {
794 return Value.Replace("<", string.Empty);
795 }
796
797 private static void PopulateNames(DocumentInformation Info, string Value)
798 {
799 string[] Parts = Value.TrimEnd('<').Split("<<", 2, StringSplitOptions.None);
800 Info.PrimaryIdentifier = SplitIdentifierPart(Parts[0]);
801 Info.SecondaryIdentifier = Parts.Length > 1 ? SplitIdentifierPart(Parts[1]) : [];
802 }
803
804 private static string[] SplitIdentifierPart(string Value)
805 {
806 return Value.Split('<', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
807 }
808
809 private static DocumentInformation? AssembleTd2Info2(Match Match)
810 {
811 if (!TryParseTd2OverflowTail(
812 Match.Groups["Nr1"].Value,
813 Match.Groups["Tail"].Value,
814 Match.Groups["Birth"].Value,
815 Match.Groups["BirthCheck"].Value,
816 Match.Groups["Expires"].Value,
817 Match.Groups["ExpiryCheck"].Value,
818 Match.Groups["OverallCheck"].Value,
819 out string? DocumentNumberSuffix,
820 out string? NumberCheck,
821 out string? OptionalData))
822 return null;
823
824 DocumentInformation Result = AssembleInfo(Match);
825 Result.DocumentNumber = Match.Groups["Nr1"].Value + DocumentNumberSuffix;
826 Result.OptionalData = OptionalData;
827 return CalcTd2OverflowMrzInfo(Result, Match, NumberCheck, OptionalData) ? Result : null;
828 }
829
830 private static DocumentInformation? AssembleTd1Info2(Match Match)
831 {
832 DocumentInformation Result = AssembleInfo(Match);
833 Result.DocumentNumber = Match.Groups["Nr1"].Value + Match.Groups["Nr2"].Value;
834 return CalcTd1MrzInfo(Result, Match) ? Result : null;
835 }
836
837 private static DocumentInformation? AssembleTd1Info1(Match Match)
838 {
839 DocumentInformation Result = AssembleInfo(Match);
840 Result.DocumentNumber = Match.Groups["Nr"].Value;
841 return CalcTd1MrzInfo(Result, Match) ? Result : null;
842 }
843
844 private static DocumentInformation? AssembleTd2Info1(Match Match)
845 {
846 DocumentInformation Result = AssembleInfo(Match);
847 Result.DocumentNumber = Match.Groups["Nr"].Value;
848 return CalcMrzInfo(Result, Match, MrzLayout.Td2) ? Result : null;
849 }
850
851 private static DocumentInformation? AssembleTd3Info1(Match Match)
852 {
853 DocumentInformation Result = AssembleInfo(Match);
854 Result.DocumentNumber = Match.Groups["Nr"].Value;
855 return CalcMrzInfo(Result, Match, MrzLayout.Td3) ? Result : null;
856 }
857
858 private static bool CalcTd1MrzInfo(DocumentInformation Info, Match Match)
859 {
860 if (Info.DocumentNumber is null || Info.DateOfBirth is null || Info.ExpiryDate is null)
861 return false;
862
863 string NumberCheck = Match.Groups["NrCheck"].Value;
864 if (NumberCheck != CalculateCheckDigitText(Info.DocumentNumber))
865 return false;
866
867 string BirthCheck = Match.Groups["BirthCheck"].Value;
868 if (BirthCheck != CalculateCheckDigitText(Info.DateOfBirth))
869 return false;
870
871 string ExpiryCheck = Match.Groups["ExpiryCheck"].Value;
872 if (ExpiryCheck != CalculateCheckDigitText(Info.ExpiryDate))
873 return false;
874
875 string OptionalLine1 = Match.Groups["Optional1"].Value;
876 string OptionalLine2 = Match.Groups["Optional2"].Value;
877 string OverallCheck = Match.Groups["OverallCheck"].Value;
878 string Composite = Info.DocumentNumber + NumberCheck + OptionalLine1 + Info.DateOfBirth + BirthCheck + Info.ExpiryDate + ExpiryCheck + OptionalLine2;
879 if (OverallCheck != CalculateCheckDigitText(Composite))
880 return false;
881
882 Info.OptionalData = (OptionalLine1 + OptionalLine2).Replace("<", string.Empty);
883 Info.MRZ_Information = Info.DocumentNumber + NumberCheck + Info.DateOfBirth + BirthCheck + Info.ExpiryDate + ExpiryCheck;
884 Info.DocumentNumber = Info.DocumentNumber.Replace("<", string.Empty);
885 return true;
886 }
887
888 private static bool CalcTd2OverflowMrzInfo(DocumentInformation Info, Match Match, string NumberCheck, string OptionalData)
889 {
890 if (Info.DocumentNumber is null || Info.DateOfBirth is null || Info.ExpiryDate is null)
891 return false;
892
893 if (NumberCheck != CalculateCheckDigitText(Info.DocumentNumber))
894 return false;
895
896 string BirthCheck = Match.Groups["BirthCheck"].Value;
897 if (BirthCheck != CalculateCheckDigitText(Info.DateOfBirth))
898 return false;
899
900 string ExpiryCheck = Match.Groups["ExpiryCheck"].Value;
901 if (ExpiryCheck != CalculateCheckDigitText(Info.ExpiryDate))
902 return false;
903
904 string Composite = Info.DocumentNumber + NumberCheck + Info.DateOfBirth + BirthCheck + Info.ExpiryDate + ExpiryCheck + OptionalData;
905 if (Match.Groups["OverallCheck"].Value != CalculateCheckDigitText(Composite))
906 return false;
907
908 Info.OptionalData = OptionalData.Replace("<", string.Empty);
909 Info.MRZ_Information = Info.DocumentNumber + NumberCheck + Info.DateOfBirth + BirthCheck + Info.ExpiryDate + ExpiryCheck;
910 Info.DocumentNumber = Info.DocumentNumber.Replace("<", string.Empty);
911 return true;
912 }
913
914 private static bool CalcMrzInfo(DocumentInformation Info, Match Match, MrzLayout Layout)
915 {
916 if (Info.DocumentNumber is null || Info.DateOfBirth is null || Info.ExpiryDate is null)
917 return false;
918
919 string NumberCheck = Match.Groups["NrCheck"].Value;
920 if (NumberCheck != CalculateCheckDigitText(Info.DocumentNumber))
921 return false;
922
923 string BirthCheck = Match.Groups["BirthCheck"].Value;
924 if (BirthCheck != CalculateCheckDigitText(Info.DateOfBirth))
925 return false;
926
927 string ExpiryCheck = Match.Groups["ExpiryCheck"].Value;
928 if (ExpiryCheck != CalculateCheckDigitText(Info.ExpiryDate))
929 return false;
930
931 string OptionalValue = Match.Groups["Optional"].Value;
932 if (Layout == MrzLayout.Td3)
933 {
934 if (!Match.Groups["OptionalCheck"].Success)
935 return false;
936
937 string OptionalCheck = Match.Groups["OptionalCheck"].Value;
938 if (OptionalCheck != CalculateCheckDigitText(OptionalValue))
939 return false;
940 }
941
942 if (Match.Groups["OverallCheck"].Success)
943 {
944 string Composite = Layout switch
945 {
946 MrzLayout.Td2 => Info.DocumentNumber + NumberCheck + Info.DateOfBirth + BirthCheck + Info.ExpiryDate + ExpiryCheck + OptionalValue,
947 MrzLayout.Td3 => Info.DocumentNumber + NumberCheck + Info.DateOfBirth + BirthCheck + Info.ExpiryDate + ExpiryCheck + OptionalValue + Match.Groups["OptionalCheck"].Value,
948 _ => string.Empty
949 };
950
951 if (Match.Groups["OverallCheck"].Value != CalculateCheckDigitText(Composite))
952 return false;
953 }
954
955 Info.OptionalData = OptionalValue.Replace("<", string.Empty);
956 Info.MRZ_Information = Info.DocumentNumber + NumberCheck + Info.DateOfBirth + BirthCheck + Info.ExpiryDate + ExpiryCheck;
957 Info.DocumentNumber = Info.DocumentNumber.Replace("<", string.Empty);
958 return true;
959 }
960
961 private static DocumentInformation AssembleInfo(Match Match)
962 {
963 DocumentInformation Result = new DocumentInformation
964 {
965 DocumentType = ExtractDocumentType(Match.Groups["DocType"].Value),
966 IssuingState = Match.Groups["Issuer"].Value,
967 Nationality = Match.Groups["Nationality"].Value,
968 Gender = Match.Groups["Gender"].Value,
969 DocumentNumber = Match.Groups["Nr"].Value,
970 DateOfBirth = Match.Groups["Birth"].Value,
971 ExpiryDate = Match.Groups["Expires"].Value,
972 OptionalData = Match.Groups["Optional"].Value
973 };
974 Result.PrimaryIdentifier = SplitIdentifierPart(Match.Groups["PID"].Value);
975 Result.SecondaryIdentifier = SplitIdentifierPart(Match.Groups["SID"].Value);
976 return Result;
977 }
978
979 private static bool TryParseTd2OverflowTail(
980 string DocumentNumberPrefix,
981 string Tail,
982 string DateOfBirth,
983 string BirthCheck,
984 string ExpiryDate,
985 string ExpiryCheck,
986 string OverallCheck,
987 [NotNullWhen(true)] out string? DocumentNumberSuffix,
988 [NotNullWhen(true)] out string? NumberCheck,
989 [NotNullWhen(true)] out string? OptionalData)
990 {
991 for (int CheckDigitIndex = 0; CheckDigitIndex < Tail.Length; CheckDigitIndex++)
992 {
993 string CandidateSuffix = Tail.Substring(0, CheckDigitIndex);
994 string CandidateNumberCheck = Tail.Substring(CheckDigitIndex, 1);
995 string CandidateOptionalData = Tail.Substring(CheckDigitIndex + 1);
996 string CandidateDocumentNumber = DocumentNumberPrefix + CandidateSuffix;
997
998 if (CandidateNumberCheck != CalculateCheckDigitText(CandidateDocumentNumber))
999 continue;
1000
1001 string Composite = CandidateDocumentNumber
1002 + CandidateNumberCheck
1003 + DateOfBirth
1004 + BirthCheck
1005 + ExpiryDate
1006 + ExpiryCheck
1007 + CandidateOptionalData;
1008 if (OverallCheck != CalculateCheckDigitText(Composite))
1009 continue;
1010
1011 DocumentNumberSuffix = CandidateSuffix;
1012 NumberCheck = CandidateNumberCheck;
1013 OptionalData = CandidateOptionalData;
1014 return true;
1015 }
1016
1017 DocumentNumberSuffix = null;
1018 NumberCheck = null;
1019 OptionalData = null;
1020 return false;
1021 }
1022
1023 private static readonly int[] Weights = [7, 3, 1];
1024
1025 private delegate DocumentInformation? DocumentInformationFromMatch(Match Match);
1026
1027 private readonly record struct ParserDefinition(
1028 MrzLayout Layout,
1029 Regex Pattern,
1030 DocumentInformationFromMatch Factory);
1031 }
1032}
Contains parsed information from a machine-readable document information string.
Represents the outcome of MRZ parsing and validation.
Definition: MrzValidator.cs:35
string NormalizedMrz
Gets the normalized MRZ text used for validation.
Definition: MrzValidator.cs:75
bool IsParsed
Gets a value indicating whether the MRZ parsed into a document model.
Definition: MrzValidator.cs:65
int RequiredCheckCount
Gets the number of required checks for the detected layout.
MrzLayout? Layout
Gets the detected MRZ layout, when one could be determined.
Definition: MrzValidator.cs:70
int PassedRequiredCheckCount
Gets the number of required checks that passed.
bool CompositeCheckPassed
Gets a value indicating whether the layout composite or overall check passed.
string? FailureReason
Gets the validation failure reason when parsing was unsuccessful.
DocumentInformation? DocumentInformation
Gets the parsed document information when parsing succeeded.
Definition: MrzValidator.cs:80
bool DateOfBirthCheckPassed
Gets a value indicating whether the date-of-birth check digit passed.
Definition: MrzValidator.cs:90
bool DocumentNumberCheckPassed
Gets a value indicating whether the document-number check digit passed.
Definition: MrzValidator.cs:85
bool ExpiryCheckPassed
Gets a value indicating whether the expiry-date check digit passed.
Definition: MrzValidator.cs:95
Provides shared MRZ parsing and validation services for OCR and NFC flows.
static bool TryParse(string Mrz, [NotNullWhen(true)] out DocumentInformation? Info)
Attempts to parse MRZ text into document information.
static MrzValidationResult Validate(string Mrz)
Validates MRZ text and returns detailed check information.
static char CalculateCheckDigit(string Value)
Calculates the MRZ check digit for a value.
Definition: ImplTypes.g.cs:58
MrzLayout
Identifies the supported MRZ layouts.
Definition: MrzValidator.cs:14
DocumentType
Type of markdown document.
Gender
Gender classification in a legal ID.
Definition: Gender.cs:7