ePassport: render the MRZ personal number without its fillers

9303 Part 4, positions 29 to 42: "Any special characters, including spaces,
in the personal identification number ... shall be replaced by the filler
character (<). The number shall be followed by the filler character (<)
repeated up to position 42."

So the field holds two different kinds of filler.  The trailing run is
padding, which was already stripped, but a filler inside the number stands
for a space or special character substituted on the way in, and those were
printed as they came - a US passport showed its personal number with a '<'
in the middle of it.

Strip the padding, then put a space back for what remains.  It is the
closest recovery available: the original could have been a hyphen, and the
MRZ does not keep which.

optional_data itself stays a faithful copy of the field.  Nothing else
reads it, and it is the personal number that is being rendered here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Kilar
2026-09-01 14:44:34 -04:00
co-authored by Claude Opus 5
parent aadcda0cb8
commit 8bb7d1191a
3 changed files with 68 additions and 4 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ All notable changes to this project will be documented in this file.
This project uses the changelog in accordance with [keepchangelog](http://keepachangelog.com/). Please use this to write notable changes, which is not the same as git commit log...
## [unreleased][unreleased]
- Fixed `tools/ePassport` - a document number longer than nine characters left its overflow in the MRZ optional-data field, where it was reported as the holder's personal number (@pkilar)
- Fixed `tools/ePassport` - the personal number read from the MRZ optional-data field showed a filler character where a space belonged, and a document number longer than nine characters left its overflow there (@pkilar)
- Added `tools/ePassport` - EF_DG13 is decoded, and a Polish document's PESEL is shown as the personal number when it carries no EF_DG11 (@pkilar)
- Fixed `tools/ePassport` - a disabled button drew Kivy's banded disabled texture, which left its label unreadable (@pkilar)
- Fixed `hf 14b` - a card asking for a waiting time extension of 4 or more left the timeout at zero, so any ISO14443-B card needing time to answer looked like it had stopped responding (@pkilar)
+14 -3
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from .mrz import Mrz
from .mrz import FILLER, Mrz
class FileState:
@@ -81,6 +81,17 @@ class DocumentDetails:
return not any(v for k, v in vars(self).items() if not k.startswith("image_"))
def _mrz_personal_number(field: str) -> str:
"""The MRZ optional-data field read as a personal number.
9303 replaces every space and special character in the number with ``<``
and then pads the field with the same character, so the trailing run is
padding while a filler inside the number stood for something we cannot
recover. A space is the closest we can put back.
"""
return field.rstrip(FILLER).replace(FILLER, " ").strip()
#: Poland carries the PESEL, its national identity number, in this DG13 tag.
PESEL_TAG = "5F70"
@@ -232,7 +243,7 @@ class PassportRecord:
if self.personal.personal_number:
return self.personal.personal_number
if self.mrz is not None:
optional = self.mrz.optional_data.value.strip()
optional = _mrz_personal_number(self.mrz.optional_data.value)
if optional:
return optional
if self.dg13_personal_number:
@@ -258,7 +269,7 @@ class PassportRecord:
"""Where :attr:`personal_number` came from, for an honest caption."""
if self.personal.personal_number:
return "DG11"
if self.mrz is not None and self.mrz.optional_data.value.strip():
if self.mrz is not None and _mrz_personal_number(self.mrz.optional_data.value):
return "MRZ"
if self.dg13_personal_number:
return "DG13"
@@ -0,0 +1,53 @@
"""The personal number taken from the MRZ optional-data field.
ICAO 9303 Part 4, positions 29 to 42: "Any special characters, including
spaces, in the personal identification number ... shall be replaced by the
filler character (<). The number shall be followed by the filler character
(<) repeated up to position 42."
So the trailing run is padding, and a filler inside the number stands for a
space or a special character that was substituted on the way in. Printing it
raw put a '<' in the middle of the number.
"""
from __future__ import annotations
from epassport.emrtd.model import PassportRecord
from epassport.emrtd.mrz import Checked, Mrz
def _record(optional: str) -> PassportRecord:
record = PassportRecord()
blank = Checked("")
record.mrz = Mrz(
kind="TD3",
lines=[],
document_number=blank,
date_of_birth=blank,
date_of_expiry=blank,
composite=blank,
nationality="USA",
optional_data=Checked(optional),
)
return record
def test_a_filler_inside_the_number_stands_for_a_space() -> None:
assert _record("123456789<0123").personal_number == "123456789 0123"
def test_trailing_padding_never_reaches_the_value() -> None:
assert _record("ZE184226B").personal_number == "ZE184226B"
assert _record("ZE184226B<<<<<").personal_number == "ZE184226B"
def test_a_plain_number_is_untouched() -> None:
assert _record("6512168").personal_number == "6512168"
def test_an_empty_field_yields_no_number() -> None:
assert _record("").personal_number_source == ""
def test_the_source_is_still_reported_as_the_mrz() -> None:
assert _record("123456789<0123").personal_number_source == "MRZ"