#!/usr/bin/env node /** * Génère les .expected.json à partir des PDFs du dossier. * * Usage : * cd e2e/fixtures/invoices * node generate-expected.mjs * * Dépendance externe : `pdftotext` (poppler-utils). En local Mac : * brew install poppler * * Une fois généré, vérifier les fichiers .expected.json à la main — * la regex peut rater des cas spéciaux. Les corriger directement si * besoin, ce script ne sera pas relancé sur les fichiers existants * (skip si déjà présent). */ import { readdirSync, existsSync, writeFileSync } from 'node:fs' import { execSync } from 'node:child_process' import { join, basename, extname } from 'node:path' const DIR = new URL('.', import.meta.url).pathname // "21/04/2026" → "2026-04-21" function parseFrDate(s) { const m = s.match(/(\d{2})\/(\d{2})\/(\d{4})/u) if (!m) return null return `${m[3]}-${m[2]}-${m[1]}` } // "21 avril 2026" → "2026-04-21" const MONTHS_FR = { janvier: '01', février: '02', mars: '03', avril: '04', mai: '05', juin: '06', juillet: '07', août: '08', septembre: '09', octobre: '10', novembre: '11', décembre: '12', } function parseFrDateLong(s) { const m = s.match(/(\d{1,2})\s+([a-zûéè]+)\s+(\d{4})/iu) if (!m) return null const month = MONTHS_FR[m[2].toLowerCase()] if (!month) return null return `${m[3]}-${month}-${m[1].padStart(2, '0')}` } // "85,34 €" → 8534 cents function parseAmountCents(s) { // Supprime espaces (séparateur de milliers PDF: "2 775,02") et le € const cleaned = s.replace(/[\s ]/gu, '').replace('€', '').replace(',', '.') const n = parseFloat(cleaned) return Number.isNaN(n) ? null : Math.round(n * 100) } function extractFields(text) { // numero : F2026-XXXX const numero = text.match(/F\d{4}-\d{4}/u)?.[0] ?? null // dates : on collecte TOUS les patterns possibles let issueDate = null let dueDate = null const issueShort = text.match(/Émise\s*(?:le|:)\s*(\d{2}\/\d{2}\/\d{4})/u) if (issueShort) issueDate = parseFrDate(issueShort[1]) const issueLong = text.match(/Date d'émission\s*:\s*([^\n]+)/u) if (!issueDate && issueLong) issueDate = parseFrDateLong(issueLong[1]) const dueShort = text.match(/Échéance\s*(?:le)?\s*:?\s*(\d{2}\/\d{2}\/\d{4})/u) if (dueShort) dueDate = parseFrDate(dueShort[1]) const dueLong = text.match(/Date d'échéance\s*:\s*([^\n]+)/u) if (!dueDate && dueLong) dueDate = parseFrDateLong(dueLong[1]) // Template Boulangerie (ADRESSÉE À + ÉCHÉANCE côte à côte) : la date // d'échéance est sur la ligne suivante, deuxième colonne. On capture // toutes les dates DD/MM/YYYY dans le text et on prend la 2e si la 1re // est issueDate. if (!dueDate) { const allDates = [...text.matchAll(/(\d{2}\/\d{2}\/\d{4})/gu)].map((m) => parseFrDate(m[1]), ) if (allDates.length >= 2 && issueDate) { dueDate = allDates.find((d) => d !== issueDate) ?? null } } // Montant TTC const ttcMatch = text.match(/TOTAL\s+TTC\s*:?\s*([0-9\s .,]+)\s*€/iu) const amountTtcCents = ttcMatch ? parseAmountCents(ttcMatch[1]) : null // clientName : 3 templates connus // 1. "DOIT : Maison Dupont" → captureGroup // 2. "Facturé à :\n École Saint-Michel" → ligne suivante non-vide // 3. Template Boulangerie côte-à-côte ADRESSÉE À + ÉCHÉANCE let clientName = null const doitMatch = text.match(/DOIT\s*:\s+([^\n]+)/u) if (doitMatch) { const candidate = doitMatch[1].trim() // Ignorer si le candidat est juste "ÉCHÉANCE" (template à colonnes). if (candidate && !/^ÉCHÉANCE/u.test(candidate)) { clientName = candidate } } if (!clientName) { // Template "Facturé à :" en colonne droite — adresse émetteur à // gauche, nom client en colonne droite séparée par ≥ 2 espaces. // Ex : // "22 place du Marché Facturé à :" // "59000 Lille" // " Pharmacie de la Gare" // → on cherche les lignes suivantes après "Facturé à :" qui ont du // text en colonne droite (non-vide après split par 2+ espaces). const factureIdx = text.search(/Facturé à\s*:/iu) if (factureIdx >= 0) { const after = text.slice(factureIdx) const lines = after.split('\n').slice(1) // skip la ligne du label // Colonne où démarre "Facturé à :" — heuristique : position dans // la ligne du label après split de l'original. const labelLine = text.slice(0, factureIdx + 60).split('\n').pop() ?? '' const labelColStart = labelLine.search(/Facturé à\s*:/iu) const colThreshold = labelColStart > 10 ? labelColStart - 5 : 30 for (const line of lines) { // Prendre ce qui est en colonne droite (à partir de colThreshold) if (line.length < colThreshold) continue const rightCol = line.slice(colThreshold).trim() if ( rightCol.length === 0 || /^ÉCHÉANCE/u.test(rightCol) || /^\d/u.test(rightCol) || // CP comme "75015 Paris" /^Mme |^M\. |^Mlle |^Dr\. |^Service |^SIRET/u.test(rightCol) ) { continue } clientName = rightCol break } } } if (!clientName) { // Template Boulangerie : "ADRESSÉE À ... ÉCHÉANCE" sur une ligne. // La ligne suivante a le nom client en col gauche + la date en col droite, // séparés par ≥ 2 espaces (pdftotext -layout). const adresseeIdx = text.indexOf('ADRESSÉE À') if (adresseeIdx >= 0) { const after = text.slice(adresseeIdx).split('\n') // Skip la ligne du header ADRESSÉE À + ÉCHÉANCE, prendre la suivante non-vide const candidates = after.slice(1).filter((l) => l.trim().length > 0) for (const line of candidates) { const cols = line.split(/\s{2,}/u).map((c) => c.trim()).filter(Boolean) const first = cols[0] if (first && !/^\d{2}\/\d{2}\/\d{4}/u.test(first) && !/^Soit /u.test(first)) { clientName = first break } } } } return { clientName, clientEmail: null, // Pas visible sur ces factures (email = émetteur, pas client) numero, amountTtcCents, issueDate, dueDate, } } const pdfs = readdirSync(DIR).filter((f) => extname(f).toLowerCase() === '.pdf') let generated = 0 let skipped = 0 let failed = 0 for (const pdf of pdfs) { const expectedPath = join(DIR, basename(pdf, '.pdf') + '.expected.json') if (existsSync(expectedPath)) { skipped += 1 continue } const text = execSync(`pdftotext -layout "${join(DIR, pdf)}" -`, { encoding: 'utf-8', }) const fields = extractFields(text) if ( !fields.numero || !fields.amountTtcCents || !fields.issueDate || !fields.dueDate || !fields.clientName ) { console.error(`✗ ${pdf} — champs manquants :`, fields) failed += 1 continue } const note = pdf.replace(/\.pdf$/u, '').replace(/-/gu, ' ') const payload = { expected: fields, notes: note, } writeFileSync(expectedPath, JSON.stringify(payload, null, 2) + '\n') generated += 1 console.log(`✔ ${pdf} → ${basename(expectedPath)}`) } console.log(`\n${generated} généré(s), ${skipped} skippé(s), ${failed} en erreur.`) if (failed > 0) process.exit(1)