espace-paie-odentas/extract-signature-positions.js
odentas b790faf12c feat: Implémentation complète du système Odentas Sign
- Remplacement de DocuSeal par solution souveraine Odentas Sign
- Système d'authentification OTP pour signataires (bcryptjs + JWT)
- 8 routes API: send-otp, verify-otp, sign, pdf-url, positions, status, webhook, signers
- Interface moderne avec canvas de signature et animations (framer-motion, confetti)
- Système de templates pour auto-détection des positions de signature (CDDU, RG, avenants)
- PDF viewer avec @react-pdf-viewer (compatible Next.js)
- Stockage S3: source/, signatures/, evidence/, signed/, certs/
- Tables Supabase: sign_requests, signers, sign_positions, sign_events, sign_assets
- Evidence bundle automatique (JSON metadata + timestamps)
- Templates emails: OTP et completion
- Scripts Lambda prêts: pades-sign (KMS seal) et tsaStamp (RFC3161)
- Mode test détecté automatiquement (emails whitelist)
- Tests complets avec PDF CDDU réel (2 signataires)
2025-10-27 19:03:07 +01:00

308 lines
10 KiB
JavaScript
Executable file

#!/usr/bin/env node
/**
* Outil pour extraire les positions exactes des placeholders DocuSeal
* Format: {{Label;role=Role;type=signature;height=H;width=W}}
*
* Utilise pdf-lib pour obtenir les vraies coordonnées
*/
const fs = require('fs');
const path = require('path');
const { PDFDocument } = require('pdf-lib');
/**
* Extrait les placeholders du texte
*/
function extractPlaceholdersFromText(text) {
const regex = /\{\{([^;]+);role=([^;]+);type=([^;]+);height=(\d+);width=(\d+)\}\}/g;
const placeholders = [];
let match;
while ((match = regex.exec(text)) !== null) {
placeholders.push({
fullMatch: match[0],
label: match[1].trim(),
role: match[2].trim(),
type: match[3].trim(),
height: parseInt(match[4]),
width: parseInt(match[5]),
startIndex: match.index,
endIndex: match.index + match[0].length,
});
}
return placeholders;
}
/**
* Lit le contenu textuel d'un PDF avec pdf-lib
* Note: pdf-lib ne fournit pas directement les positions du texte
* On va donc utiliser une approche hybride
*/
async function analyzePdfWithLib(pdfPath) {
const pdfBytes = fs.readFileSync(pdfPath);
const pdfDoc = await PDFDocument.load(pdfBytes);
const pages = pdfDoc.getPages();
const results = {
pageCount: pages.length,
pages: [],
};
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const { width, height } = page.getSize();
results.pages.push({
number: i + 1,
width,
height,
});
}
return results;
}
/**
* Extrait le texte brut avec pdf-parse
*/
async function extractTextWithPdfParse(pdfPath) {
try {
const pdfParse = require('pdf-parse');
const dataBuffer = fs.readFileSync(pdfPath);
const data = await pdfParse(dataBuffer);
return data.text;
} catch (error) {
console.warn('⚠️ pdf-parse non disponible, utilisation de méthode alternative');
return null;
}
}
/**
* Méthode alternative : lire le PDF comme texte brut et chercher les patterns
*/
function extractTextFromPdfRaw(pdfPath) {
const pdfBytes = fs.readFileSync(pdfPath);
const pdfText = pdfBytes.toString('utf-8');
return pdfText;
}
/**
* Estime la position Y basée sur la fréquence du texte dans le document
* Méthode heuristique pour les PDFs sans extraction de coordonnées
*/
function estimatePositions(placeholders, pdfInfo, totalText) {
const estimatedPositions = [];
for (const placeholder of placeholders) {
// Chercher le contexte autour du placeholder
const contextBefore = totalText.substring(Math.max(0, placeholder.startIndex - 200), placeholder.startIndex);
const contextAfter = totalText.substring(placeholder.endIndex, Math.min(totalText.length, placeholder.endIndex + 200));
// Estimer la page (simplifié: diviser le document en segments)
const relativePosition = placeholder.startIndex / totalText.length;
const estimatedPage = Math.ceil(relativePosition * pdfInfo.pageCount);
// Pour un document A4 standard (842 points de hauteur)
const pageHeight = pdfInfo.pages[estimatedPage - 1]?.height || 842;
const pageWidth = pdfInfo.pages[estimatedPage - 1]?.width || 595;
// Position Y: du haut vers le bas
// Les placeholders de signature sont souvent en bas de page
const estimatedY = pageHeight * 0.2; // 20% depuis le haut (donc vers le bas)
// Position X: selon le rôle
let estimatedX = 50; // Marge gauche par défaut
if (placeholder.role.toLowerCase().includes('salarié') ||
placeholder.role.toLowerCase().includes('salarie') ||
placeholder.role.toLowerCase().includes('employé')) {
estimatedX = pageWidth / 2 + 50; // Droite de la page
}
estimatedPositions.push({
role: placeholder.role,
label: placeholder.label,
page: estimatedPage,
x: Math.round(estimatedX),
y: Math.round(estimatedY),
width: placeholder.width,
height: placeholder.height,
confidence: 'estimated', // Indiquer que c'est une estimation
context: {
before: contextBefore.substring(contextBefore.length - 50),
after: contextAfter.substring(0, 50),
},
});
}
return estimatedPositions;
}
/**
* Crée un mapping de positions par template
*/
function createTemplateMapping(positions, filename) {
const templateName = filename.replace(/[_-]\w+\.pdf$/, ''); // Enlever les ID uniques
return {
templateName,
filename,
positions: positions.map(p => ({
role: p.role,
page: p.page,
x: p.x,
y: p.y,
width: p.width,
height: p.height,
})),
metadata: {
generatedAt: new Date().toISOString(),
method: 'docuseal-placeholder-extraction',
},
};
}
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error('Usage: node extract-signature-positions.js <chemin-pdf> [--save-template]');
console.error('');
console.error('Options:');
console.error(' --save-template Sauvegarder comme template réutilisable');
console.error('');
console.error('Exemples:');
console.error(' node extract-signature-positions.js contrat_cddu.pdf');
console.error(' node extract-signature-positions.js contrat_cddu.pdf --save-template');
process.exit(1);
}
const pdfPath = args[0];
const saveTemplate = args.includes('--save-template');
if (!fs.existsSync(pdfPath)) {
console.error(`❌ Fichier introuvable: ${pdfPath}`);
process.exit(1);
}
console.log('═══════════════════════════════════════════════════════');
console.log(' 🔍 Extraction des positions DocuSeal');
console.log('═══════════════════════════════════════════════════════\n');
const filename = path.basename(pdfPath);
console.log(`📄 PDF: ${filename}\n`);
// 1. Analyser le PDF avec pdf-lib
console.log('📖 Analyse du PDF...');
const pdfInfo = await analyzePdfWithLib(pdfPath);
console.log(` Pages: ${pdfInfo.pageCount}`);
pdfInfo.pages.forEach(p => {
console.log(` Page ${p.number}: ${Math.round(p.width)}x${Math.round(p.height)} points`);
});
console.log('');
// 2. Extraire le texte
console.log('📝 Extraction du texte...');
let text = await extractTextWithPdfParse(pdfPath);
if (!text) {
text = extractTextFromPdfRaw(pdfPath);
}
console.log(` ${text.length} caractères extraits\n`);
// 3. Trouver les placeholders
console.log('🔍 Recherche des placeholders DocuSeal...');
const placeholders = extractPlaceholdersFromText(text);
if (placeholders.length === 0) {
console.log(' ❌ Aucun placeholder DocuSeal trouvé');
console.log('');
console.log(' Format attendu: {{Label;role=Role;type=signature;height=H;width=W}}');
console.log(' Exemple: {{Signature Employé;role=Salarié;type=signature;height=60;width=150}}');
process.exit(1);
}
console.log(`${placeholders.length} placeholder(s) trouvé(s):\n`);
placeholders.forEach((p, i) => {
console.log(` ${i + 1}. ${p.label}`);
console.log(` Rôle: ${p.role}`);
console.log(` Type: ${p.type}`);
console.log(` Dimensions: ${p.width}x${p.height}px`);
console.log('');
});
// 4. Estimer les positions
console.log('📍 Calcul des positions...');
const positions = estimatePositions(placeholders, pdfInfo, text);
console.log('');
console.log('═══════════════════════════════════════════════════════');
console.log(' 📊 Positions extraites');
console.log('═══════════════════════════════════════════════════════\n');
positions.forEach((pos, i) => {
console.log(`${i + 1}. ${pos.role} - "${pos.label}"`);
console.log(` Page: ${pos.page}`);
console.log(` Position: (${pos.x}, ${pos.y})`);
console.log(` Dimensions: ${pos.width}x${pos.height}px`);
console.log(` Confiance: ${pos.confidence}`);
console.log(` Contexte avant: ...${pos.context.before}`);
console.log(` Contexte après: ${pos.context.after}...`);
console.log('');
});
// 5. Format pour l'API
console.log('═══════════════════════════════════════════════════════');
console.log(' 💻 Format pour create-real-signature.js');
console.log('═══════════════════════════════════════════════════════\n');
const positionsObject = {};
positions.forEach(pos => {
positionsObject[pos.role] = {
page: pos.page,
x: pos.x,
y: pos.y,
width: pos.width,
height: pos.height,
};
});
console.log('```javascript');
console.log('const positions = ' + JSON.stringify(positionsObject, null, 2) + ';');
console.log('```\n');
// 6. Sauvegarder comme template si demandé
if (saveTemplate) {
const template = createTemplateMapping(positions, filename);
const templateDir = path.join(__dirname, 'signature-templates');
if (!fs.existsSync(templateDir)) {
fs.mkdirSync(templateDir);
}
const templateFile = path.join(templateDir, `${template.templateName}.json`);
fs.writeFileSync(templateFile, JSON.stringify(template, null, 2));
console.log(`💾 Template sauvegardé: ${templateFile}\n`);
}
// 7. Avertissement sur les estimations
console.log('⚠️ IMPORTANT:');
console.log(' Les positions sont ESTIMÉES car pdf-lib ne peut pas extraire');
console.log(' les coordonnées exactes du texte.');
console.log('');
console.log(' Pour des positions PRÉCISES, utilisez une de ces méthodes:');
console.log(' 1. Créer un template manuel basé sur vos vrais documents');
console.log(' 2. Utiliser pdf.js (plus complexe mais précis)');
console.log(' 3. Ajuster manuellement les coordonnées après tests');
console.log('');
console.log('💡 Conseil: Testez avec create-real-signature.js et ajustez');
console.log(' les positions si nécessaire.\n');
}
main().catch(error => {
console.error('❌ Erreur:', error.message);
process.exit(1);
});