id_verif/index.html

551 lines
23 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Vérification locale de carte d'identité (OCR + MRZ)</title>
<style>
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Helvetica, Arial, sans-serif;
line-height: 1.45;
margin: 24px
}
h1 {
font-size: 1.35rem;
margin: 0 0 8px
}
h2 {
font-size: 1.05rem;
margin: 16px 0 8px
}
.row {
display: flex;
gap: 16px;
flex-wrap: wrap
}
.col {
flex: 1 1 360px
}
.box {
border: 1px solid #ccc;
border-radius: 8px;
padding: 12px
}
label {
display: block;
margin: 8px 0 4px
}
img {
max-width: 100%;
height: auto;
border: 1px solid #ddd;
border-radius: 6px
}
pre {
white-space: pre-wrap;
background: #fafafa;
border: 1px solid #eee;
padding: 8px;
border-radius: 6px;
max-height: 260px;
overflow: auto
}
table {
border-collapse: collapse;
width: 100%
}
th,
td {
border: 1px solid #ddd;
padding: 6px 8px;
text-align: left
}
.ok {
color: #156f00;
font-weight: 600
}
.ko {
color: #9c1515;
font-weight: 600
}
.warn {
color: #7a5d00;
font-weight: 600
}
.kv {
font-family: ui-monospace, Consolas, Monaco, monospace
}
.muted {
color: #555
}
.progress {
height: 10px;
background: #eee;
border-radius: 999px;
overflow: hidden
}
.bar {
height: 100%;
width: 0%;
background: #999;
transition: width .2s
}
.mono {
font-family: ui-monospace, Consolas, Monaco, monospace
}
code {
font-family: ui-monospace, Consolas, Monaco, monospace
}
.hint {
font-size: .92rem;
margin-top: 8px
}
</style>
<!-- Tesseract.js auto-hébergé pour éviter les erreurs MIME depuis certains CDN -->
<script src="/vendor/tesseract/tesseract.min.js"></script>
</head>
<body>
<h1>Vérification locale de carte d'identité (OCR + MRZ)</h1>
<div class="row">
<div class="col box">
<label for="file">Image de la pièce (photo/scan, MRZ nette)</label>
<input id="file" type="file" accept="image/*">
<div class="progress" style="margin:12px 0">
<div id="bar" class="bar"></div>
</div>
<button id="run">Analyser l'image</button>
<div class="hint muted">
Conseils: recadrer pour que la MRZ (2×44 ou 3×30 caractères) soit horizontale et contrastée. Éviter les reflets.
</div>
<h2>Aperçu</h2>
<img id="preview" alt="">
<h2>Texte OCR (brut)</h2>
<pre id="raw"></pre>
</div>
<div class="col box">
<h2>Résultats</h2>
<table id="results">
<tbody></tbody>
</table>
<h2>Score de vraisemblance</h2>
<div id="scoreBlock">
<div class="kv">Score total: <span id="score"></span> / 100</div>
<pre id="scoreDetails"></pre>
</div>
<h2>Données structurées</h2>
<pre id="json"></pre>
</div>
</div>
<script>
// ---------- paramètres Tesseract.js épinglés pour usage en HTML statique ----------
// Chemins locaux pour worker et core
const WORKER_PATH = '/vendor/tesseract/worker.min.js';
const CORE_BASE = '/vendor/tesseract-core/'; // fichiers à la racine du module
const LANG_PATH = '/vendor/tessdata';
// ---------- éléments UI ----------
const fileInput = document.getElementById( 'file' );
const preview = document.getElementById( 'preview' );
const bar = document.getElementById( 'bar' );
const raw = document.getElementById( 'raw' );
const resultsBody = document.querySelector( '#results tbody' );
const scoreEl = document.getElementById( 'score' );
const scoreDetails = document.getElementById( 'scoreDetails' );
const jsonOut = document.getElementById( 'json' );
fileInput.addEventListener( 'change', e => {
const f = e.target.files[ 0 ];
if ( !f ) return;
const url = URL.createObjectURL( f );
preview.src = url;
raw.textContent = '';
} );
// ---------- OCR correctement initialisé ----------
async function recognizeImage ( file ) {
// création du worker avec chemins explicites pour fonctionner en file://
const worker = await Tesseract.createWorker( {
logger: m => {
if ( m.status === 'recognizing text' && m.progress != null ) {
bar.style.width = Math.round( m.progress * 100 ) + '%';
}
},
workerPath: WORKER_PATH,
corePath: CORE_BASE,
langPath: LANG_PATH,
} );
try {
// langues
await worker.loadLanguage( 'fra+eng' );
await worker.initialize( 'fra+eng' );
// reconnaissance
return await worker.recognize( file );
} finally {
await worker.terminate();
}
}
document.getElementById( 'run' ).addEventListener( 'click', async () => {
const f = fileInput.files[ 0 ];
if ( !f ) { alert( 'Choisir une image.' ); return; }
if ( !f.type.startsWith( 'image/' ) ) { alert( 'Fichier non image.' ); return; }
if ( f.size < 1024 ) { alert( 'Fichier trop petit ou vide.' ); return; }
clearOutputs();
try {
const res = await recognizeImage( f );
const text = res?.data?.text || '';
raw.textContent = text;
const analysis = analyzeText( text );
renderAnalysis( analysis );
} catch ( err ) {
console.error( err );
alert( 'Échec OCR. Vérifier laccès réseau au CDN pour charger le modèle, ou tester sous serveur local.' );
}
} );
// ---------- analyse MRZ (ICAO Doc 9303 TD1/TD3) ----------
const ISO3 = {
FRA: "France", DZA: "Algérie", MAR: "Maroc", TUN: "Tunisie", DEU: "Allemagne", ESP: "Espagne", ITA: "Italie",
PRT: "Portugal", BEL: "Belgique", NLD: "Pays-Bas", LUX: "Luxembourg", CHE: "Suisse", GBR: "Royaume-Uni",
IRL: "Irlande", USA: "États-Unis", CAN: "Canada", MEX: "Mexique", BRA: "Brésil", ARG: "Argentine",
CHN: "Chine", JPN: "Japon", KOR: "Corée du Sud", IND: "Inde", RUS: "Russie", UKR: "Ukraine",
POL: "Pologne", CZE: "Tchéquie", SVK: "Slovaquie", HUN: "Hongrie", AUT: "Autriche", SWE: "Suède",
NOR: "Norvège", DNK: "Danemark", FIN: "Finlande", ISL: "Islande", GRC: "Grèce", ROU: "Roumanie",
BGR: "Bulgarie", HRV: "Croatie", SVN: "Slovénie", EST: "Estonie", LVA: "Lettonie", LTU: "Lituanie",
CYP: "Chypre", MLT: "Malte"
};
function isoName ( code ) { return ISO3[ code ]?.toString() || null; }
function analyzeText ( text ) {
const norm = text
.toUpperCase()
.replace( /[^\x20-\x7E]/g, '' )
.replace( /[ ]+/g, ' ' )
.replace( /\u00AB|\u00BB/g, '<' )
.replace( /[|]/g, '<' );
const lines = norm.split( /\r?\n/ ).map( l => l.trim() ).filter( Boolean );
const joined = lines.join( '\n' );
const td1 = findTD1( lines );
const td3 = findTD3( lines );
let mrz = null;
let format = null;
if ( td1 ) { mrz = td1; format = 'TD1'; }
else if ( td3 ) { mrz = td3; format = 'TD3'; }
let parsed = null, checks = null;
if ( mrz ) {
if ( format === 'TD1' ) { parsed = parseTD1( mrz ); checks = checkTD1( parsed ); }
else { parsed = parseTD3( mrz ); checks = checkTD3( parsed ); }
}
const allowed = /[A-Z0-9<]/;
const mrzStr = mrz ? mrz.join( '\n' ) : joined;
const mrzChars = mrzStr.replace( /\s+/g, '' ).split( '' );
const allowedCount = mrzChars.filter( ch => allowed.test( ch ) ).length;
const allowedRatio = mrzChars.length ? allowedCount / mrzChars.length : 0;
const plausibility = scorePlausibility( {
hasMRZ: !!mrz, format, checks, parsed, allowedRatio
} );
return { norm, lines, mrz, format, parsed, checks, allowedRatio, plausibility };
}
function findTD1 ( lines ) {
for ( let i = 0; i < lines.length - 2; i++ ) {
const a = lines[ i ], b = lines[ i + 1 ], c = lines[ i + 2 ];
if ( isMRZLen( a, 30 ) && isMRZLen( b, 30 ) && isMRZLen( c, 30 ) ) return [ a.slice( 0, 30 ), b.slice( 0, 30 ), c.slice( 0, 30 ) ];
}
return null;
}
function findTD3 ( lines ) {
for ( let i = 0; i < lines.length - 1; i++ ) {
const a = lines[ i ], b = lines[ i + 1 ];
if ( isMRZLen( a, 44 ) && isMRZLen( b, 44 ) ) return [ a.slice( 0, 44 ), b.slice( 0, 44 ) ];
}
return null;
}
function isMRZLen ( line, len ) {
return line && line.length >= len && /^[A-Z0-9<]+$/.test( line.slice( 0, len ) );
}
function parseTD1 ( [ L1, L2, L3 ] ) {
L1 = L1.padEnd( 30, '<' ); L2 = L2.padEnd( 30, '<' ); L3 = L3.padEnd( 30, '<' );
const docCode = L1.slice( 0, 2 );
const issuing = L1.slice( 2, 5 );
const namesRaw = L1.slice( 5, 30 ).replace( /<+/g, ' ' ).trim();
const [ surname, given ] = splitNames( namesRaw );
const docNumber = L2.slice( 0, 9 ).replace( /</g, '' );
const docNumberChk = L2[ 9 ];
const nationality = L2.slice( 10, 13 );
const birth = L2.slice( 13, 19 );
const birthChk = L2[ 19 ];
const sex = L2[ 20 ];
const expiry = L2.slice( 21, 27 );
const expiryChk = L2[ 27 ];
const optA = L2.slice( 28, 30 );
const optB = L3.slice( 0, 29 );
const compositeChk = L3[ 29 ];
const optional = ( optA + optB ).replace( /<+/g, '' ).trim() || null;
return {
format: 'TD1', docCode, issuing, surname, given,
docNumber, docNumberChk, nationality, birth, birthChk,
sex, expiry, expiryChk, optional, compositeChk,
raw: [ L1, L2, L3 ]
};
}
function parseTD3 ( [ L1, L2 ] ) {
L1 = L1.padEnd( 44, '<' ); L2 = L2.padEnd( 44, '<' );
const docCode = L1.slice( 0, 2 );
const issuing = L1.slice( 2, 5 );
const namesRaw = L1.slice( 5, 44 ).replace( /<+/g, ' ' ).trim();
const [ surname, given ] = splitNames( namesRaw );
const docNumber = L2.slice( 0, 9 ).replace( /</g, '' );
const docNumberChk = L2[ 9 ];
const nationality = L2.slice( 10, 13 );
const birth = L2.slice( 13, 19 );
const birthChk = L2[ 19 ];
const sex = L2[ 20 ];
const expiry = L2.slice( 21, 27 );
const expiryChk = L2[ 27 ];
const personal = L2.slice( 28, 42 );
const compositeChk = L2[ 43 ];
return {
format: 'TD3', docCode, issuing, surname, given,
docNumber, docNumberChk, nationality, birth, birthChk,
sex, expiry, expiryChk, optional: personal.replace( /<+/g, '' ).trim() || null,
compositeChk, raw: [ L1, L2 ]
};
}
function splitNames ( full ) {
if ( full.includes( '<<' ) ) {
const [ sn, gn ] = full.split( '<<' );
return [ sn.replace( /<+/g, ' ' ).trim(), gn?.replace( /<+/g, ' ' ).trim() || null ];
}
const words = full.split( ' ' ).filter( Boolean );
if ( words.length > 1 ) return [ words[ 0 ], words.slice( 1 ).join( ' ' ) ];
return [ full, null ];
}
// chiffres de contrôle (pondérations 7-3-1)
const weights = [ 7, 3, 1 ];
function charValue ( ch ) {
if ( ch >= '0' && ch <= '9' ) return ch.charCodeAt( 0 ) - 48;
if ( ch >= 'A' && ch <= 'Z' ) return ch.charCodeAt( 0 ) - 55;
if ( ch === '<' ) return 0;
return 0;
}
function checkDigitFor ( str ) {
let sum = 0;
for ( let i = 0; i < str.length; i++ ) {
const v = charValue( str[ i ] );
const w = weights[ i % 3 ];
sum += v * w;
}
return String( sum % 10 );
}
function checkTD1 ( p ) {
if ( !p ) return null;
const L2 = p.raw[ 1 ].slice( 0, 30 );
const L3 = p.raw[ 2 ].slice( 0, 30 );
const cDoc = checkDigitFor( p.raw[ 1 ].slice( 0, 9 ) );
const cBirth = checkDigitFor( p.birth );
const cExp = checkDigitFor( p.expiry );
const compositeField = L2 + L3.slice( 0, 29 );
const cComp = checkDigitFor( compositeField );
return {
docNumber: { expected: cDoc, actual: p.docNumberChk, valid: cDoc === p.docNumberChk },
birth: { expected: cBirth, actual: p.birthChk, valid: cBirth === p.birthChk },
expiry: { expected: cExp, actual: p.expiryChk, valid: cExp === p.expiryChk },
composite: { expected: cComp, actual: p.compositeChk, valid: cComp === p.compositeChk }
};
}
function checkTD3 ( p ) {
if ( !p ) return null;
const cDoc = checkDigitFor( p.raw[ 1 ].slice( 0, 9 ) );
const cBirth = checkDigitFor( p.birth );
const cExp = checkDigitFor( p.expiry );
const compositeField = p.raw[ 1 ].slice( 0, 10 ) + p.raw[ 1 ].slice( 13, 20 ) + p.birthChk + p.raw[ 1 ].slice( 21, 28 ) + p.expiryChk + p.raw[ 1 ].slice( 28, 42 );
const cComp = checkDigitFor( compositeField );
return {
docNumber: { expected: cDoc, actual: p.docNumberChk, valid: cDoc === p.docNumberChk },
birth: { expected: cBirth, actual: p.birthChk, valid: cBirth === p.birthChk },
expiry: { expected: cExp, actual: p.expiryChk, valid: cExp === p.expiryChk },
composite: { expected: cComp, actual: p.compositeChk, valid: cComp === p.compositeChk }
};
}
function parseYYMMDD ( yyMMdd ) {
if ( !/^[0-9]{6}$/.test( yyMMdd ) ) return null;
const yy = parseInt( yyMMdd.slice( 0, 2 ), 10 );
const mm = parseInt( yyMMdd.slice( 2, 4 ), 10 );
const dd = parseInt( yyMMdd.slice( 4, 6 ), 10 );
if ( mm < 1 || mm > 12 || dd < 1 || dd > 31 ) return null;
const year = yy <= 29 ? 2000 + yy : 1900 + yy;
const date = new Date( Date.UTC( year, mm - 1, dd ) );
if ( date.getUTCMonth() !== mm - 1 || date.getUTCDate() !== dd ) return null;
return { year, month: mm, day: dd, iso: `${ year.toString().padStart( 4, '0' ) }-${ String( mm ).padStart( 2, '0' ) }-${ String( dd ).padStart( 2, '0' ) }` };
}
function ageFrom ( birthISO ) {
const d = new Date( birthISO ); const now = new Date();
let age = now.getUTCFullYear() - d.getUTCFullYear();
const m = now.getUTCMonth() - d.getUTCMonth();
if ( m < 0 || ( m === 0 && now.getUTCDate() < d.getUTCDate() ) ) age--;
return age;
}
function scorePlausibility ( { hasMRZ, format, checks, parsed, allowedRatio } ) {
let total = 0;
const lines = [];
const W = {
mrzPresent: 20, docNumber: 15, birth: 10, expiry: 10, composite: 15,
datesPlausible: 10, nationality: 5, issuing: 5, ocrClean: 10
};
if ( hasMRZ ) { total += W.mrzPresent; lines.push( `[+${ W.mrzPresent }] MRZ détectée (${ format }).` ); }
else { lines.push( `[+0] MRZ non détectée.` ); }
if ( checks ) {
if ( checks.docNumber.valid ) { total += W.docNumber; lines.push( `[+${ W.docNumber }] Numéro: chiffre de contrôle correct (${ checks.docNumber.actual }).` ); }
else { lines.push( `[+0] Numéro: attendu ${ checks.docNumber.expected }, obtenu ${ checks.docNumber.actual }.` ); }
if ( checks.birth.valid ) { total += W.birth; lines.push( `[+${ W.birth }] Date de naissance: chiffre de contrôle correct (${ checks.birth.actual }).` ); }
else { lines.push( `[+0] Naissance: attendu ${ checks.birth.expected }, obtenu ${ checks.birth.actual }.` ); }
if ( checks.expiry.valid ) { total += W.expiry; lines.push( `[+${ W.expiry }] Expiration: chiffre de contrôle correct (${ checks.expiry.actual }).` ); }
else { lines.push( `[+0] Expiration: attendu ${ checks.expiry.expected }, obtenu ${ checks.expiry.actual }.` ); }
if ( checks.composite.valid ) { total += W.composite; lines.push( `[+${ W.composite }] Contrôle composite correct.` ); }
else { lines.push( `[+0] Contrôle composite invalide.` ); }
} else {
lines.push( `[+0] Chiffres de contrôle non évalués.` );
}
let datesOK = false;
if ( parsed ) {
const b = parseYYMMDD( parsed.birth );
const e = parseYYMMDD( parsed.expiry );
if ( b && e ) {
const age = ageFrom( b.iso );
const nowISO = new Date().toISOString().slice( 0, 10 );
const expPast = ( new Date( e.iso ) < new Date( nowISO ) );
const ageOK = age >= 0 && age <= 120;
datesOK = ageOK;
lines.push( `[info] Naissance: ${ b.iso } ⇒ âge ${ age } ans.` );
lines.push( `[info] Expiration: ${ e.iso }${ expPast ? 'document expiré' : 'valide à date' }.` );
}
}
if ( datesOK ) { total += W.datesPlausible; lines.push( `[+${ W.datesPlausible }] Dates plausibles (âge 0120).` ); }
else { lines.push( `[+0] Dates non plausibles ou illisibles.` ); }
if ( parsed ) {
const nat = isoName( parsed.nationality );
const iss = isoName( parsed.issuing );
if ( nat ) { total += W.nationality; lines.push( `[+${ W.nationality }] Nationalité reconnue (${ parsed.nationality } = ${ nat }).` ); }
else { lines.push( `[+0] Code nationalité inconnu (${ parsed?.nationality || '' }).` ); }
if ( iss ) { total += W.issuing; lines.push( `[+${ W.issuing }] Pays émetteur reconnu (${ parsed.issuing } = ${ iss }).` ); }
else { lines.push( `[+0] Code pays émetteur inconnu (${ parsed?.issuing || '' }).` ); }
}
const ocrPts = Math.round( W.ocrClean * Math.min( 1, Math.max( 0, ( allowedRatio - 0.85 ) / 0.15 ) ) );
total += ocrPts;
lines.push( `[+${ ocrPts }] Propreté OCR (ratio caractères MRZ valides = ${ ( allowedRatio * 100 ).toFixed( 1 ) } %).` );
if ( total > 100 ) total = 100;
return { total, breakdown: lines.join( '\n' ) };
}
function renderAnalysis ( a ) {
resultsBody.innerHTML = '';
function row ( k, v, cls = '' ) { const tr = document.createElement( 'tr' ); tr.innerHTML = `<th>${ k }</th><td class="kv ${ cls }">${ v ?? '' }</td>`; resultsBody.appendChild( tr ); }
row( 'Format MRZ détecté', a.format || '—', a.mrz ? 'ok' : 'ko' );
if ( a.parsed ) {
const p = a.parsed, c = a.checks;
row( 'Code document', p.docCode );
row( 'Pays émetteur', `${ p.issuing } ${ isoName( p.issuing ) ? ' ' + isoName( p.issuing ) : '' }` );
row( 'Nom', p.surname || '—' );
row( 'Prénoms', p.given || '—' );
row( 'Numéro document', p.docNumber || '—' );
if ( c ) row( 'Contrôle numéro', c.docNumber.valid ? 'valide' : `invalide (attendu ${ c.docNumber.expected }, obtenu ${ c.docNumber.actual })`, c.docNumber.valid ? 'ok' : 'ko' );
row( 'Nationalité', `${ p.nationality } ${ isoName( p.nationality ) ? ' ' + isoName( p.nationality ) : '' }` );
const b = parseYYMMDD( p.birth );
if ( b ) { row( 'Naissance', `${ b.iso } (chiffre ${ a.checks?.birth?.actual || '' })` ); row( 'Âge estimé', ageFrom( b.iso ) + ' ans' ); }
const e = parseYYMMDD( p.expiry );
if ( e ) { const expPast = new Date( e.iso ) < new Date( new Date().toISOString().slice( 0, 10 ) ); row( 'Expiration', `${ e.iso } (${ expPast ? 'expiré' : 'valide' })` ); }
row( 'Sexe (MRZ)', p.sex || '—' );
if ( a.checks ) {
row( 'Contrôle naissance', a.checks.birth.valid ? 'valide' : 'invalide', a.checks.birth.valid ? 'ok' : 'ko' );
row( 'Contrôle expiration', a.checks.expiry.valid ? 'valide' : 'invalide', a.checks.expiry.valid ? 'ok' : 'ko' );
row( 'Contrôle composite', a.checks.composite.valid ? 'valide' : 'invalide', a.checks.composite.valid ? 'ok' : 'ko' );
}
row( 'Données optionnelles', p.optional || '—' );
} else {
row( 'MRZ', 'non détectée', 'ko' );
}
scoreEl.textContent = a.plausibility.total.toString();
scoreDetails.textContent = a.plausibility.breakdown;
jsonOut.textContent = JSON.stringify( {
format: a.format || null,
parsed: a.parsed || null,
checks: a.checks || null,
allowedRatio: a.allowedRatio,
score: a.plausibility.total
}, null, 2 );
}
function clearOutputs () {
resultsBody.innerHTML = '';
raw.textContent = '';
scoreEl.textContent = '';
scoreDetails.textContent = '';
jsonOut.textContent = '';
bar.style.width = '0%';
}
</script>
</body>
</html>