feat: Migration Cloudinary vers Poppler pour conversion PDF→JPEG

- Remplacer Cloudinary (US) par solution 100% AWS eu-west-3
- Lambda odentas-sign-pdf-converter avec pdftoppm
- Lambda Layer poppler-utils v5 avec dépendances complètes
- Trigger S3 ObjectCreated pour conversion automatique
- Support multi-pages validé (PDF 3 pages)
- Stockage images dans S3 odentas-docs
- PDFImageViewer pour affichage images converties
- Conformité RGPD garantie (données EU uniquement)
This commit is contained in:
odentas 2025-10-28 10:22:45 +01:00
parent 40ab28fdc7
commit 59749d481b
33 changed files with 4812 additions and 362 deletions

View file

@ -38,3 +38,6 @@ DEBUG_UPSTREAM=0
# Used by AWS Lambda to authenticate API calls to Espace Paie
# Generate with: openssl rand -hex 32
LAMBDA_API_KEY=your-lambda-api-key-64-chars-hex
# Lambda Functions URLs
LAMBDA_PDF_TO_IMAGES_URL=https://your-lambda-url.lambda-url.eu-west-3.on.aws/

13
.env.lambda.example Normal file
View file

@ -0,0 +1,13 @@
# Configuration Lambda PDF to Images
# =================================
# URL de la Lambda AWS pour la conversion PDF vers images
# Format: https://xxx.execute-api.REGION.amazonaws.com/STAGE/convert
# Exemple: https://abc123def.execute-api.us-east-1.amazonaws.com/prod/convert
LAMBDA_PDF_TO_IMAGES_URL=
# Instructions de déploiement:
# 1. Déployez la Lambda depuis le dossier lambda-pdf-to-images/
# 2. Créez une API Gateway REST API pointant vers la Lambda
# 3. Copiez l'URL de l'API Gateway ici
# 4. Redémarrez l'application Next.js

196
LAMBDA_DEPLOYMENT.md Normal file
View file

@ -0,0 +1,196 @@
# Déploiement Lambda PDF to Images - Odentas Sign
## ✅ Déploiement Réussi
La Lambda de conversion PDF vers images a été déployée avec succès sur AWS.
### Informations de Déploiement
- **Nom de la fonction** : `odentas-pdf-to-images`
- **Région AWS** : `eu-west-3` (Paris)
- **Runtime** : Node.js 20.x
- **Mémoire** : 2048 MB
- **Timeout** : 300 secondes (5 minutes)
- **Rôle IAM** : `odentas-seal-lambda-role`
### URL de la Lambda
```
https://o4nfddsoi44rrhcrl3zlfeiury0uyasw.lambda-url.eu-west-3.on.aws/
```
Cette URL a été ajoutée automatiquement dans `.env.local` :
```env
LAMBDA_PDF_TO_IMAGES_URL=https://o4nfddsoi44rrhcrl3zlfeiury0uyasw.lambda-url.eu-west-3.on.aws/
```
### Configuration CORS
- **AllowOrigins** : `*` (tous les domaines)
- **AllowMethods** : `POST`
- **AllowHeaders** : `*`
- **Auth** : NONE (pas d'authentification requise)
Cela permet de tester en local et en production sans restrictions.
## 🚧 Prochaines Étapes
### 1. Ajouter le Layer Poppler
⚠️ **Important** : La Lambda nécessite un Layer contenant `poppler-utils` et `ImageMagick`.
Pour créer et déployer le layer :
```bash
cd lambda-pdf-to-images
./create-layer.sh # Nécessite Docker
# Puis déployer le layer
aws lambda publish-layer-version \
--layer-name poppler-imagemagick \
--description "Poppler Utils et ImageMagick" \
--zip-file fileb://poppler-layer.zip \
--compatible-runtimes nodejs20.x \
--compatible-architectures x86_64 \
--region eu-west-3
```
Ou utiliser un layer public existant :
```bash
aws lambda update-function-configuration \
--function-name odentas-pdf-to-images \
--layers arn:aws:lambda:eu-west-3:ACCOUNT:layer:poppler-imagemagick:1 \
--region eu-west-3
```
### 2. Tester la Lambda
Test avec curl :
```bash
curl -X POST https://o4nfddsoi44rrhcrl3zlfeiury0uyasw.lambda-url.eu-west-3.on.aws/ \
-H "Content-Type: application/json" \
-d '{
"pdfUrl": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
"scale": 1.5
}'
```
### 3. Redémarrer l'application Next.js
```bash
# L'URL Lambda a été ajoutée dans .env.local
# Redémarrer le serveur de développement
npm run dev
```
### 4. Déployer sur Vercel
Ajouter la variable d'environnement sur Vercel :
1. Aller dans Settings > Environment Variables
2. Ajouter :
- **Nom** : `LAMBDA_PDF_TO_IMAGES_URL`
- **Valeur** : `https://o4nfddsoi44rrhcrl3zlfeiury0uyasw.lambda-url.eu-west-3.on.aws/`
- **Environment** : Production, Preview, Development
3. Redéployer l'application
## 🔄 Mise à Jour de la Lambda
Pour mettre à jour le code de la Lambda :
```bash
cd lambda-pdf-to-images
npm install
zip -r lambda-pdf-to-images.zip index.js package.json node_modules/
aws lambda update-function-code \
--function-name odentas-pdf-to-images \
--zip-file fileb://lambda-pdf-to-images.zip \
--region eu-west-3
```
## 📊 Monitoring
Voir les logs CloudWatch :
```bash
aws logs tail /aws/lambda/odentas-pdf-to-images --follow --region eu-west-3
```
Voir les métriques :
```bash
aws cloudwatch get-metric-statistics \
--namespace AWS/Lambda \
--metric-name Invocations \
--dimensions Name=FunctionName,Value=odentas-pdf-to-images \
--start-time 2025-10-27T00:00:00Z \
--end-time 2025-10-27T23:59:59Z \
--period 3600 \
--statistics Sum \
--region eu-west-3
```
## 🔒 Sécurité
### Recommandations de Production
1. **Ajouter une authentification** : Utiliser une API Key ou JWT
2. **Limiter les origines CORS** : Remplacer `*` par les domaines autorisés
3. **Activer AWS WAF** : Protection contre les attaques DDoS
4. **Configurer des alertes CloudWatch** : Pour les erreurs et la latence
5. **Limiter la taille des PDFs** : Actuellement 50MB max
Pour activer l'authentification :
```bash
aws lambda update-function-url-config \
--function-name odentas-pdf-to-images \
--auth-type AWS_IAM \
--region eu-west-3
```
## 💰 Coûts Estimés
Avec les paramètres actuels (2048MB, 300s timeout) :
- **Prix par invocation** : ~0.0000166667 USD par seconde
- **Exemple** : Si conversion PDF prend 10 secondes → ~0.17 USD par invocation
- **1000 conversions/mois** → ~170 USD/mois
Pour réduire les coûts :
- Réduire la mémoire si possible (test avec 1024MB)
- Mettre en cache les résultats dans S3
- Utiliser CloudFront pour le CDN
## 🆘 Dépannage
### La Lambda retourne une erreur 500
Vérifier les logs :
```bash
aws logs tail /aws/lambda/odentas-pdf-to-images --follow --region eu-west-3
```
### Erreur "pdftoppm: command not found"
Le layer poppler n'est pas installé. Voir section "Ajouter le Layer Poppler".
### Timeout après 300 secondes
Le PDF est trop volumineux. Augmenter le timeout ou la mémoire :
```bash
aws lambda update-function-configuration \
--function-name odentas-pdf-to-images \
--timeout 600 \
--memory-size 3008 \
--region eu-west-3
```
## 📝 Support
- Code source : `/lambda-pdf-to-images/`
- Documentation : `/lambda-pdf-to-images/README.md`
- Équipe : Odentas Tech

View file

@ -0,0 +1,180 @@
import { NextRequest, NextResponse } from 'next/server';
import { GetObjectCommand, HeadObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { supabaseAdmin } from '@/lib/odentas-sign/supabase';
import { verifySignatureSession } from '@/lib/odentas-sign/jwt';
// Client S3
const s3Client = new S3Client({
region: process.env.AWS_REGION || 'eu-west-3',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
// Bucket où sont stockées les images converties
const IMAGES_BUCKET = process.env.AWS_S3_BUCKET || 'odentas-docs';
interface PageImage {
pageNumber: number;
imageUrl: string;
width: number;
height: number;
}
/**
* Récupère les images JPEG pré-converties depuis S3
* (converties automatiquement par la Lambda lors de l'upload du PDF)
*/
async function getPreconvertedImagesFromS3(requestId: string): Promise<PageImage[]> {
const pageImages: PageImage[] = [];
let pageNum = 1;
while (true) {
const s3Key = `odentas-sign-images/${requestId}/page-${pageNum}.jpg`;
try {
// Vérifier si la page existe
await s3Client.send(
new HeadObjectCommand({
Bucket: IMAGES_BUCKET,
Key: s3Key,
})
);
// Générer l'URL presignée (valide 24h)
const command = new GetObjectCommand({
Bucket: IMAGES_BUCKET,
Key: s3Key,
});
const s3Url = await getSignedUrl(s3Client, command, { expiresIn: 86400 });
pageImages.push({
pageNumber: pageNum,
imageUrl: s3Url,
width: 1400,
height: Math.round(1400 * 1.414), // Ratio A4
});
pageNum++;
} catch (error) {
// Plus de pages, on sort de la boucle
break;
}
}
return pageImages;
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
console.log('[PDF to Images API] Nouvelle requête', { requestId: params.id });
// Verify authorization header
const authHeader = request.headers.get('authorization');
console.log('[PDF to Images API] Auth header:', {
present: !!authHeader,
startsWithBearer: authHeader?.startsWith('Bearer '),
length: authHeader?.length,
});
if (!authHeader?.startsWith('Bearer ')) {
console.error('[PDF to Images API] Header Authorization manquant ou invalide');
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
const sessionToken = authHeader.substring(7);
console.log('[PDF to Images API] Token extrait:', {
length: sessionToken.length,
preview: sessionToken.substring(0, 20) + '...',
});
// Vérifier et décoder le JWT
const session = verifySignatureSession(sessionToken);
console.log('[PDF to Images API] Session vérifiée:', {
valid: !!session,
signerId: session?.signerId,
requestId: session?.requestId,
});
if (!session) {
console.error('[PDF to Images API] Session JWT invalide');
return NextResponse.json({ error: 'Session invalide' }, { status: 401 });
}
// Verify request ID matches
if (session.requestId !== params.id) {
console.error('[PDF to Images API] RequestId mismatch:', {
sessionRequestId: session.requestId,
paramsId: params.id,
});
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
}
console.log('[PDF to Images API] Requête Supabase pour requestId:', params.id);
// Get signature request (on n'a plus besoin du PDF, juste vérifier que la demande existe)
const { data: requestData, error: requestError } = await supabaseAdmin
.from('sign_requests')
.select('id')
.eq('id', params.id)
.single();
console.log('[PDF to Images API] Résultat Supabase:', {
hasData: !!requestData,
hasError: !!requestError,
error: requestError,
});
if (requestError || !requestData) {
console.error('[PDF to Images API] Demande non trouvée dans Supabase:', {
requestId: params.id,
error: requestError,
});
return NextResponse.json({ error: 'Demande non trouvée' }, { status: 404 });
}
console.log('[PDF to Images API] Récupération des images pré-converties depuis S3...');
// Récupérer les images JPEG pré-converties depuis S3
// (converties automatiquement par la Lambda lors de l'upload du PDF)
const pages = await getPreconvertedImagesFromS3(params.id);
if (pages.length === 0) {
console.error('[PDF to Images API] Aucune image trouvée dans S3');
return NextResponse.json(
{
error: 'Images non disponibles',
details: 'Les images du PDF n\'ont pas encore été converties. Veuillez réessayer dans quelques instants.',
},
{ status: 404 }
);
}
console.log(`[PDF to Images API] ✅ ${pages.length} page(s) récupérées depuis S3`);
return NextResponse.json({
success: true,
pages: pages.map((page: PageImage) => ({
pageNumber: page.pageNumber,
imageUrl: page.imageUrl,
width: page.width,
height: page.height,
})),
totalPages: pages.length,
});
} catch (error) {
console.error('[PDF to Images API] Erreur:', error);
return NextResponse.json(
{
error: 'Erreur lors de la conversion PDF',
details: error instanceof Error ? error.message : 'Erreur inconnue',
},
{ status: 500 }
);
}
}

View file

@ -1,6 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifySignatureSession } from '@/lib/odentas-sign/jwt';
import { supabaseAdmin } from '@/lib/odentas-sign/supabase';
import { getPresignedDownloadUrl } from '@/lib/odentas-sign/s3';
import {
extractPlaceholdersFromPdfBuffer,
countPdfPagesFromBytes,
estimatePositionsFromPlaceholders,
} from '@/lib/odentas-sign/placeholders';
/**
* GET /api/odentas-sign/requests/:id/positions
@ -32,7 +38,7 @@ export async function GET(
);
}
// Récupérer toutes les positions de signature
// Récupérer toutes les positions de signature existantes
const { data: positions, error } = await supabaseAdmin
.from('sign_positions')
.select('page, x, y, w, h, role')
@ -47,19 +53,82 @@ export async function GET(
);
}
// Transformer pour correspondre à l'interface frontend
const transformedPositions = positions.map(p => ({
// Si positions déjà présentes, renvoyer directement
if (positions && positions.length > 0) {
const transformedPositions = positions.map((p) => ({
page: p.page,
x: p.x,
y: p.y,
width: p.w,
height: p.h,
role: p.role,
}));
return NextResponse.json({ positions: transformedPositions });
}
// Pas de positions en DB: tenter d'extraire depuis le PDF via placeholders
// 1) Récupérer la clé S3 du PDF source
const { data: signRequest, error: requestErr } = await supabaseAdmin
.from('sign_requests')
.select('source_s3_key')
.eq('id', requestId)
.single();
if (requestErr || !signRequest?.source_s3_key) {
console.error('Impossible de récupérer sign_request pour extraction:', requestErr);
return NextResponse.json({ positions: [] });
}
// 2) Générer une URL présignée et télécharger le PDF
const pdfUrl = await getPresignedDownloadUrl(signRequest.source_s3_key, 300);
const resp = await fetch(pdfUrl);
if (!resp.ok) {
console.error('Téléchargement du PDF échoué:', resp.status, pdfUrl);
return NextResponse.json({ positions: [] });
}
const arrayBuf = await resp.arrayBuffer();
const bytes = Buffer.from(arrayBuf);
// 3) Fallback regex + estimation (pas d'extraction pdfjs côté serveur)
const placeholders = extractPlaceholdersFromPdfBuffer(bytes);
if (!placeholders || placeholders.length === 0) {
return NextResponse.json({ positions: [] });
}
const pageCount = countPdfPagesFromBytes(bytes);
const precise = estimatePositionsFromPlaceholders(placeholders, pageCount);
// 5) Persister en DB pour cette demande (meilleure UX aux prochains chargements)
try {
const rows = precise.map((pos) => ({
request_id: requestId,
role: pos.role,
page: pos.page,
x: pos.x, // mm
y: pos.y, // mm
w: pos.width, // mm
h: pos.height, // mm
kind: 'signature',
label: pos.label,
}));
const { error: insertErr } = await supabaseAdmin.from('sign_positions').insert(rows);
if (insertErr) {
console.warn('Insertion positions estimées échouée (non bloquant):', insertErr);
}
} catch (e) {
console.warn('Erreur lors de la persistance des positions estimées:', e);
}
// 6) Retourner au format attendu par le front
const transformedPositions = precise.map((p) => ({
page: p.page,
x: p.x,
y: p.y,
width: p.w,
height: p.h,
width: p.width,
height: p.height,
role: p.role,
}));
return NextResponse.json({
positions: transformedPositions,
});
return NextResponse.json({ positions: transformedPositions });
} catch (error) {
console.error('Erreur lors de la récupération des positions:', error);
@ -69,3 +138,73 @@ export async function GET(
);
}
}
/**
* POST /api/odentas-sign/requests/:id/positions
* Upsert des positions détectées côté client (mm)
*/
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const requestId = params.id;
// Vérifier le token JWT
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ error: 'Token manquant ou invalide' },
{ status: 401 }
);
}
const token = authHeader.split(' ')[1];
const payload = verifySignatureSession(token);
if (!payload || payload.requestId !== requestId) {
return NextResponse.json(
{ error: 'Token invalide ou expiré' },
{ status: 401 }
);
}
const body = await request.json();
const positions = Array.isArray(body?.positions) ? body.positions : [];
if (positions.length === 0) {
return NextResponse.json({ success: false, message: 'Aucune position fournie' }, { status: 400 });
}
// Nettoyer les positions existantes de type signature pour cette requête
await supabaseAdmin
.from('sign_positions')
.delete()
.eq('request_id', requestId)
.eq('kind', 'signature');
// Insérer les nouvelles positions (mm)
const rows = positions.map((p: any) => ({
request_id: requestId,
role: p.role,
page: p.page,
x: p.x,
y: p.y,
w: p.w,
h: p.h,
kind: p.kind || 'signature',
label: p.label || null,
}));
const { error: insertErr } = await supabaseAdmin
.from('sign_positions')
.insert(rows);
if (insertErr) {
console.error('Erreur insertion positions (POST):', insertErr);
return NextResponse.json({ error: 'Insertion des positions échouée' }, { status: 500 });
}
return NextResponse.json({ success: true, inserted: rows.length });
} catch (error) {
console.error('Erreur POST positions:', error);
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
}
}

View file

@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server';
import { PDFDocument } from 'pdf-lib';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const pdfUrl = searchParams.get('url');
const requestId = searchParams.get('requestId');
if (!pdfUrl) {
return NextResponse.json(
{ error: 'URL du PDF requise' },
{ status: 400 }
);
}
// Récupérer le PDF depuis S3
const response = await fetch(decodeURIComponent(pdfUrl));
if (!response.ok) {
return NextResponse.json(
{ error: `Erreur S3: ${response.status}` },
{ status: response.status }
);
}
const pdfBytes = await response.arrayBuffer();
try {
// Charger le PDF avec pdf-lib
const pdfDoc = await PDFDocument.load(pdfBytes);
const pages = pdfDoc.getPages();
// Parcourir chaque page et extraire les annotations
// Note: pdf-lib ne peut pas modifier directement le texte rendu
// On va donc simplement retourner le PDF tel quel
// car les placeholders seront masqués par les overlays de signature
const modifiedPdfBytes = await pdfDoc.save();
return new NextResponse(Buffer.from(modifiedPdfBytes), {
headers: {
'Content-Type': 'application/pdf',
'Cache-Control': 'public, max-age=3600',
'Access-Control-Allow-Origin': '*',
},
});
} catch (error) {
console.error('Erreur parsing PDF avec pdf-lib:', error);
// En cas d'erreur, retourner le PDF original
return new NextResponse(Buffer.from(pdfBytes), {
headers: {
'Content-Type': 'application/pdf',
'Cache-Control': 'public, max-age=3600',
'Access-Control-Allow-Origin': '*',
},
});
}
} catch (error) {
console.error('Erreur nettoyage PDF:', error);
return NextResponse.json(
{ error: 'Erreur serveur' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const pdfUrl = searchParams.get('url');
if (!pdfUrl) {
return NextResponse.json(
{ error: 'URL du PDF requise' },
{ status: 400 }
);
}
// Décoder l'URL si elle est encodée
const decodedUrl = decodeURIComponent(pdfUrl);
// Fetcher le PDF depuis S3
const response = await fetch(decodedUrl);
if (!response.ok) {
return NextResponse.json(
{ error: `Erreur S3: ${response.status}` },
{ status: response.status }
);
}
const buffer = await response.arrayBuffer();
// Retourner le PDF avec les headers CORS appropriés
return new NextResponse(buffer, {
headers: {
'Content-Type': 'application/pdf',
'Cache-Control': 'public, max-age=3600',
'Access-Control-Allow-Origin': '*',
},
});
} catch (error) {
console.error('Erreur proxy PDF:', error);
return NextResponse.json(
{ error: 'Erreur serveur' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,171 @@
'use client';
import { useState, useEffect } from 'react';
import { Loader2, AlertCircle } from 'lucide-react';
interface SignPosition {
page: number;
x: number;
y: number;
width: number;
height: number;
role: string;
}
interface PDFImageViewerProps {
pdfUrl: string;
positions: SignPosition[];
currentSignerRole: string;
requestId: string;
sessionToken: string;
}
interface PageImage {
pageNumber: number;
imageUrl: string;
width: number;
height: number;
}
export default function PDFImageViewer({
pdfUrl,
positions,
currentSignerRole,
requestId,
sessionToken,
}: PDFImageViewerProps) {
const [pageImages, setPageImages] = useState<PageImage[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function convertPdfToImages() {
try {
setIsLoading(true);
setError(null);
console.log('[PDFImageViewer] Début conversion', {
requestId,
pdfUrl,
hasSessionToken: !!sessionToken,
sessionTokenLength: sessionToken?.length,
sessionTokenPreview: sessionToken?.substring(0, 20) + '...',
});
// Appel API pour convertir le PDF en images
const response = await fetch(`/api/odentas-sign/requests/${requestId}/pdf-to-images`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${sessionToken}`,
},
body: JSON.stringify({
pdfUrl,
}),
});
if (!response.ok) {
const data = await response.json();
console.error('[PDFImageViewer] Erreur response:', {
status: response.status,
statusText: response.statusText,
data,
});
throw new Error(data.error || 'Erreur lors de la conversion du PDF');
}
const data = await response.json();
setPageImages(data.pages || []);
} catch (err) {
console.error('[PDFImageViewer] Erreur:', err);
setError(err instanceof Error ? err.message : 'Erreur de chargement');
} finally {
setIsLoading(false);
}
}
if (pdfUrl) {
convertPdfToImages();
}
}, [pdfUrl, requestId, sessionToken]);
if (isLoading) {
return (
<div className="h-full bg-slate-50 rounded-xl flex flex-col items-center justify-center">
<Loader2 className="w-12 h-12 text-indigo-600 animate-spin mb-4" />
<p className="text-slate-600 font-medium">Conversion du PDF en cours...</p>
</div>
);
}
if (error) {
return (
<div className="h-full bg-red-50 rounded-xl flex flex-col items-center justify-center p-6">
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
<p className="text-red-700 font-medium mb-2">Erreur de chargement</p>
<p className="text-red-600 text-sm text-center">{error}</p>
</div>
);
}
if (pageImages.length === 0) {
return (
<div className="h-full bg-slate-50 rounded-xl flex flex-col items-center justify-center">
<p className="text-slate-500">Aucune page à afficher</p>
</div>
);
}
return (
<div className="h-full overflow-y-auto space-y-4">
{pageImages.map((page) => {
// Filtrer les positions de signature pour cette page et ce rôle
const pagePositions = positions.filter(
(pos) => pos.page === page.pageNumber && pos.role === currentSignerRole
);
return (
<div
key={page.pageNumber}
className="relative bg-white border border-slate-200 rounded-lg overflow-hidden"
style={{
aspectRatio: `${page.width} / ${page.height}`,
}}
>
{/* Image de la page PDF */}
<img
src={page.imageUrl}
alt={`Page ${page.pageNumber}`}
className="w-full h-full object-contain"
/>
{/* Zones de signature superposées */}
{pagePositions.map((pos, idx) => (
<div
key={idx}
className="absolute border-2 border-dashed border-indigo-500 bg-indigo-100/30 pointer-events-none"
style={{
left: `${pos.x * 100}%`,
top: `${pos.y * 100}%`,
width: `${pos.width * 100}%`,
height: `${pos.height * 100}%`,
}}
>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-semibold text-indigo-700 bg-white/80 px-2 py-1 rounded">
Signez ici
</span>
</div>
</div>
))}
{/* Numéro de page */}
<div className="absolute bottom-2 right-2 bg-slate-900/70 text-white text-xs px-2 py-1 rounded">
Page {page.pageNumber}
</div>
</div>
);
})}
</div>
);
}

View file

@ -1,84 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { Viewer, Worker } from '@react-pdf-viewer/core';
import { defaultLayoutPlugin } from '@react-pdf-viewer/default-layout';
// Import des styles
import '@react-pdf-viewer/core/lib/styles/index.css';
import '@react-pdf-viewer/default-layout/lib/styles/index.css';
interface SignaturePosition {
page: number;
x: number;
y: number;
width: number;
height: number;
role: string;
}
interface PDFViewerProps {
pdfUrl: string;
positions: SignaturePosition[];
currentSignerRole: string;
}
export default function PDFViewer({ pdfUrl, positions, currentSignerRole }: PDFViewerProps) {
const [mounted, setMounted] = useState(false);
// Plugin pour la mise en page par défaut
const defaultLayoutPluginInstance = defaultLayoutPlugin();
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<div className="flex items-center justify-center h-full bg-gray-50 rounded-lg border border-gray-200">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Initialisation du viewer...</p>
</div>
</div>
);
}
return (
<div className="h-full bg-gray-50 rounded-lg overflow-hidden border border-gray-200">
<Worker workerUrl={`https://unpkg.com/pdfjs-dist@3.11.174/build/pdf.worker.min.js`}>
<div className="relative h-full">
<Viewer
fileUrl={pdfUrl}
plugins={[defaultLayoutPluginInstance]}
defaultScale={1.2}
/>
{/* Overlay custom pour les zones de signature */}
<div className="absolute top-16 right-4 bg-white/95 backdrop-blur-sm p-3 rounded-lg shadow-lg border border-gray-200">
<h4 className="text-xs font-semibold text-gray-700 mb-2">Zones de signature</h4>
<div className="space-y-2">
{positions.map((pos, index) => {
const isCurrentSigner = pos.role === currentSignerRole;
return (
<div
key={index}
className={`flex items-center gap-2 text-xs ${
isCurrentSigner ? 'text-blue-600 font-semibold' : 'text-gray-600'
}`}
>
{isCurrentSigner ? '✍️' : '📝'}
<span>Page {pos.page}: {pos.role}</span>
</div>
);
})}
</div>
{positions.length === 0 && (
<p className="text-xs text-gray-500 italic">Aucune zone définie</p>
)}
</div>
</div>
</Worker>
</div>
);
}

View file

@ -3,6 +3,18 @@
import { useState, useRef, useEffect } from 'react';
import { motion } from 'framer-motion';
import { PenTool, RotateCcw, Check, Loader2, AlertCircle, FileText, Info } from 'lucide-react';
import dynamic from 'next/dynamic';
// Charger le PDFImageViewer côté client uniquement (conversion PDF vers images comme Docuseal)
const PDFImageViewer = dynamic(() => import('./PDFImageViewer'), {
ssr: false,
loading: () => (
<div className="h-[700px] bg-slate-50 rounded-xl flex flex-col items-center justify-center">
<Loader2 className="w-12 h-12 text-indigo-600 animate-spin mb-4" />
<p className="text-slate-600 font-medium">Chargement du visualiseur...</p>
</div>
),
});
interface SignatureCaptureProps {
signerId: string;
@ -44,20 +56,6 @@ export default function SignatureCapture({
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [signaturePositions, setSignaturePositions] = useState<SignPosition[]>([]);
const [isPdfLoading, setIsPdfLoading] = useState(true);
const [PDFViewerComponent, setPDFViewerComponent] = useState<any>(null);
// Load PDF Viewer component (client-side only)
useEffect(() => {
async function loadPDFViewer() {
try {
const { default: PDFViewer } = await import('./PDFViewer');
setPDFViewerComponent(() => PDFViewer);
} catch (err) {
console.error('[PDF] Erreur chargement viewer:', err);
}
}
loadPDFViewer();
}, []);
// Load PDF and signature positions
useEffect(() => {
@ -242,170 +240,188 @@ export default function SignatureCapture({
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="max-w-3xl mx-auto"
className="w-full min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 py-8 px-4"
>
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 px-8 py-8 text-white">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold mb-2">Signature du document</h2>
<p className="text-indigo-100">{documentTitle}</p>
</div>
<div className="text-right">
<p className="text-xs text-indigo-200 uppercase tracking-wider mb-1">Signataire</p>
<p className="font-semibold">{signerName}</p>
<p className="text-sm text-indigo-100">{signerRole}</p>
<div className="bg-white rounded-2xl shadow-xl overflow-hidden mb-6">
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 px-8 py-6 text-white">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold mb-2">Signature du document</h1>
<p className="text-indigo-100 text-lg">{documentTitle}</p>
</div>
<div className="text-right">
<p className="text-xs text-indigo-200 uppercase tracking-wider mb-1">Signataire</p>
<p className="font-semibold text-lg">{signerName}</p>
<p className="text-sm text-indigo-100">{signerRole}</p>
</div>
</div>
</div>
</div>
{/* Content */}
<div className="p-8">
{/* PDF Viewer */}
{isPdfLoading ? (
<div className="mb-8 bg-slate-50 rounded-xl p-12 flex flex-col items-center justify-center">
<Loader2 className="w-8 h-8 text-indigo-600 animate-spin mb-3" />
<p className="text-slate-600">Chargement du document...</p>
</div>
) : pdfUrl && PDFViewerComponent ? (
<div className="mb-8">
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
<FileText className="w-5 h-5 text-indigo-600" />
Aperçu du document
</h3>
<div className="h-[600px]">
<PDFViewerComponent
pdfUrl={pdfUrl}
positions={signaturePositions}
currentSignerRole={signerRole}
/>
{/* Two-column layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left: PDF Viewer */}
<div className="lg:col-span-1">
<div className="bg-white rounded-2xl shadow-xl overflow-hidden h-full">
<div className="px-6 py-4 border-b border-slate-200 bg-slate-50">
<h2 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
<FileText className="w-5 h-5 text-indigo-600" />
Document à signer
</h2>
</div>
</div>
) : null}
{/* Info notice */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-8 flex gap-3">
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-900">
<p className="font-medium mb-1">Dessinez votre signature</p>
<p className="text-blue-700">
Utilisez votre souris, trackpad ou doigt pour signer dans le cadre ci-dessous. Vous pouvez recommencer à tout moment.
</p>
<div className="p-4">
{isPdfLoading ? (
<div className="h-[700px] bg-slate-50 rounded-xl flex flex-col items-center justify-center">
<Loader2 className="w-12 h-12 text-indigo-600 animate-spin mb-4" />
<p className="text-slate-600 font-medium">Chargement du document...</p>
</div>
) : pdfUrl ? (
<div className="h-[700px]">
<PDFImageViewer
pdfUrl={pdfUrl}
positions={signaturePositions}
currentSignerRole={signerRole}
requestId={requestId}
sessionToken={sessionToken}
/>
</div>
) : (
<div className="h-[700px] bg-slate-50 rounded-xl flex flex-col items-center justify-center">
<FileText className="w-16 h-16 text-slate-300 mb-4" />
<p className="text-slate-500">Aucun document à afficher</p>
</div>
)}
</div>
</div>
</div>
{/* Signature canvas */}
<div className="mb-6">
<div className="border-2 border-dashed border-slate-300 rounded-xl overflow-hidden bg-white relative group hover:border-indigo-400 transition-colors">
<canvas
ref={canvasRef}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
onTouchStart={startDrawing}
onTouchMove={draw}
onTouchEnd={stopDrawing}
className="w-full h-64 cursor-crosshair touch-none"
style={{ touchAction: 'none' }}
/>
{/* Right: Signature panel */}
<div className="lg:col-span-1">
<div className="bg-white rounded-2xl shadow-xl overflow-hidden sticky top-8">
<div className="px-6 py-4 border-b border-slate-200 bg-slate-50">
<h2 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
<PenTool className="w-5 h-5 text-indigo-600" />
Votre signature
</h2>
</div>
{!hasDrawn && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
<PenTool className="w-8 h-8 text-slate-400 mx-auto mb-2" />
<p className="text-slate-500 text-sm">Signez ici</p>
<div className="p-6">
{/* Info notice */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6 flex gap-3">
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-900">
<p className="font-medium mb-1">Dessinez votre signature</p>
<p className="text-blue-700">
Utilisez votre souris, trackpad ou doigt pour signer dans le cadre ci-dessous.
</p>
</div>
</div>
)}
{/* Signature canvas */}
<div className="mb-6">
<div className="border-2 border-dashed border-slate-300 rounded-xl overflow-hidden bg-white relative group hover:border-indigo-400 transition-colors">
<canvas
ref={canvasRef}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
onTouchStart={startDrawing}
onTouchMove={draw}
onTouchEnd={stopDrawing}
className="w-full h-48 cursor-crosshair touch-none"
style={{ touchAction: 'none' }}
/>
{!hasDrawn && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
<PenTool className="w-8 h-8 text-slate-400 mx-auto mb-2" />
<p className="text-slate-500 text-sm">Signez ici</p>
</div>
</div>
)}
</div>
{/* Clear button */}
{hasDrawn && (
<motion.button
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
onClick={clearSignature}
disabled={isSubmitting}
className="mt-3 px-4 py-2 text-sm text-slate-700 hover:text-slate-900 font-medium flex items-center gap-2 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
>
<RotateCcw className="w-4 h-4" />
Recommencer
</motion.button>
)}
</div>
{/* Consent checkbox */}
<div className="bg-slate-50 rounded-xl p-5 mb-6">
<label className="flex items-start gap-3 cursor-pointer group">
<div className="flex-shrink-0 pt-1">
<input
type="checkbox"
checked={consentChecked}
onChange={(e) => setConsentChecked(e.target.checked)}
disabled={isSubmitting}
className="w-5 h-5 text-indigo-600 border-slate-300 rounded focus:ring-indigo-500 focus:ring-2 cursor-pointer"
/>
</div>
<div className="text-sm text-slate-700 leading-relaxed">
<p>
Je consens à signer électroniquement le document <span className="font-semibold">"{documentTitle}"</span> et confirme que cette signature a la même valeur juridique qu'une signature manuscrite.
</p>
<p className="mt-2 text-xs text-slate-500">
Signature horodatée et archivée de manière sécurisée pendant 10 ans (eIDAS).
</p>
</div>
</label>
</div>
{/* Error message */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl flex items-start gap-3"
>
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">{error}</p>
</motion.div>
)}
{/* Submit button */}
<button
onClick={submitSignature}
disabled={!hasDrawn || !consentChecked || isSubmitting}
className="w-full px-6 py-4 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 hover:shadow-lg flex items-center justify-center gap-2 text-lg"
>
{isSubmitting ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Signature en cours...
</>
) : (
<>
<Check className="w-5 h-5" />
Valider ma signature
</>
)}
</button>
{/* Help text */}
<p className="mt-4 text-center text-xs text-slate-500">
En validant, vous acceptez que votre signature soit juridiquement contraignante.
</p>
</div>
</div>
{/* Clear button */}
{hasDrawn && (
<motion.button
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
onClick={clearSignature}
disabled={isSubmitting}
className="mt-4 px-4 py-2 text-sm text-slate-700 hover:text-slate-900 font-medium flex items-center gap-2 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
>
<RotateCcw className="w-4 h-4" />
Recommencer
</motion.button>
)}
</div>
{/* Consent checkbox */}
<div className="bg-slate-50 rounded-xl p-6 mb-6">
<label className="flex items-start gap-4 cursor-pointer group">
<div className="flex-shrink-0 pt-1">
<input
type="checkbox"
checked={consentChecked}
onChange={(e) => setConsentChecked(e.target.checked)}
disabled={isSubmitting}
className="w-5 h-5 text-indigo-600 border-slate-300 rounded focus:ring-indigo-500 focus:ring-2 cursor-pointer"
/>
</div>
<div className="text-sm text-slate-700 leading-relaxed">
<p>
Je consens à signer électroniquement le document <span className="font-semibold">"{documentTitle}"</span> et confirme que cette signature a la même valeur juridique qu'une signature manuscrite.
</p>
<p className="mt-2 text-xs text-slate-500">
Votre signature sera horodatée, scellée et archivée de manière sécurisée pendant 10 ans conformément à la réglementation eIDAS.
</p>
</div>
</label>
</div>
{/* Error message */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl flex items-start gap-3"
>
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">{error}</p>
</motion.div>
)}
{/* Submit button */}
<button
onClick={submitSignature}
disabled={!hasDrawn || !consentChecked || isSubmitting}
className="w-full px-8 py-4 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 hover:shadow-lg flex items-center justify-center gap-2"
>
{isSubmitting ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Signature en cours...
</>
) : (
<>
<Check className="w-5 h-5" />
Valider ma signature
</>
)}
</button>
{/* Help text */}
<p className="mt-4 text-center text-sm text-slate-500">
En validant, vous acceptez que votre signature soit juridiquement contraignante.
</p>
</div>
</div>
{/* Document preview (placeholder for future PDF viewer) */}
<div className="mt-8 bg-white rounded-2xl shadow-xl p-8">
<div className="flex items-center gap-3 mb-4">
<FileText className="w-6 h-6 text-slate-600" />
<h3 className="text-lg font-semibold text-slate-900">Aperçu du document</h3>
</div>
<div className="bg-slate-100 rounded-xl p-8 text-center">
<p className="text-slate-600">Le visualiseur de PDF sera intégré prochainement</p>
<p className="text-sm text-slate-500 mt-2">Référence: {requestId.slice(0, 8)}...</p>
</div>
</div>
</motion.div>

127
extract-placeholders.js Normal file
View file

@ -0,0 +1,127 @@
#!/usr/bin/env node
/**
* Script pour extraire les positions exactes des placeholders {{Signature...}}
* depuis un PDF
*/
const fs = require('fs');
const path = require('path');
/**
* Regex pour matcher les placeholders de signature
* Format: {{Label;role=Role;type=type;height=H;width=W}}
*/
const PLACEHOLDER_REGEX = /\{\{([^;]+);role=([^;]+);type=([^;]+);height=(\d+);width=(\d+)\}\}/g;
async function extractPlaceholders(pdfPath) {
console.log(`\n📄 Analyse du PDF: ${pdfPath}\n`);
try {
// Lire le PDF comme texte brut
const pdfBuffer = fs.readFileSync(pdfPath);
const pdfText = pdfBuffer.toString('latin1');
console.log(`✅ PDF chargé\n`);
const placeholders = [];
let pageNum = 1;
// Chercher les placeholders dans le texte
let match;
PLACEHOLDER_REGEX.lastIndex = 0; // Reset la regex
while ((match = PLACEHOLDER_REGEX.exec(pdfText)) !== null) {
placeholders.push({
page: pageNum, // On va essayer de déterminer la page
label: match[1].trim(),
role: match[2].trim(),
type: match[3].trim(),
height: parseInt(match[4]),
width: parseInt(match[5]),
fullMatch: match[0],
});
}
// Essayer de mieux déterminer les pages en cherchant les marqueurs de page
const pageBreakRegex = /\x0c/g; // Form feed character
let pageBreakMatch;
let lastBreakIndex = 0;
let pageBreaks = [0];
while ((pageBreakMatch = pageBreakRegex.exec(pdfText)) !== null) {
pageBreaks.push(pageBreakMatch.index);
}
// Associer les placeholders aux pages correctes
placeholders.forEach(ph => {
const placeholderPos = pdfText.indexOf(ph.fullMatch);
if (placeholderPos !== -1) {
// Trouver quelle page contient ce placeholder
for (let i = pageBreaks.length - 1; i >= 0; i--) {
if (placeholderPos >= pageBreaks[i]) {
ph.page = i + 1;
break;
}
}
}
});
if (placeholders.length === 0) {
console.log('⚠️ Aucun placeholder trouvé!\n');
return;
}
// Afficher les résultats
console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
console.log('📍 PLACEHOLDERS DÉTECTÉS');
console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
placeholders.forEach((ph, idx) => {
console.log(`${idx + 1}. ${ph.label}`);
console.log(` Rôle: ${ph.role}`);
console.log(` Page: ${ph.page}`);
console.log(` Dimensions: ${ph.width} × ${ph.height} mm`);
console.log(` Type: ${ph.type}\n`);
});
// Générer le code pour test-odentas-sign.js
console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
console.log('📋 CODE POUR test-odentas-sign.js');
console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
console.log('positions: [');
placeholders.forEach(ph => {
console.log(` {`);
console.log(` role: '${ph.role}',`);
console.log(` page: ${ph.page},`);
console.log(` x: 100, // À ajuster selon la position exacte`);
console.log(` y: 680, // À ajuster selon la position exacte`);
console.log(` w: ${ph.width},`);
console.log(` h: ${ph.height},`);
console.log(` kind: 'signature',`);
console.log(` label: '${ph.label}',`);
console.log(` },`);
});
console.log(']');
// Sauvegarder en JSON
const outputPath = pdfPath.replace('.pdf', '-placeholders.json');
fs.writeFileSync(outputPath, JSON.stringify(placeholders, null, 2));
console.log(`\n💾 Résultats sauvegardés: ${outputPath}\n`);
} catch (error) {
console.error('❌ Erreur:', error.message);
process.exit(1);
}
}
// Point d'entrée
const pdfPath = process.argv[2] || path.join(__dirname, 'test-contrat.pdf');
if (!fs.existsSync(pdfPath)) {
console.error(`❌ Fichier non trouvé: ${pdfPath}`);
process.exit(1);
}
extractPlaceholders(pdfPath).catch(console.error);

View file

@ -0,0 +1,213 @@
#!/usr/bin/env node
/**
* Script pour extraire les positions EXACTES des placeholders depuis un PDF
* Utilise pdfjs-dist pour accéder au texte rendu et ses coordonnées
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const http = require('http');
// Placeholder patterns à chercher
const PATTERNS = [
/Signature Employeur/i,
/Signature Employé/i,
/Signature Salarié/i,
/\{\{Signature[^}]*\}\}/g,
];
/**
* Télécharge un fichier depuis une URL
*/
function downloadFile(url, filepath) {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http;
const file = fs.createWriteStream(filepath);
protocol.get(url, (response) => {
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}).on('error', (err) => {
fs.unlink(filepath, () => {});
reject(err);
});
});
}
/**
* Extrait les positions des placeholders en utilisant une approche basée sur le texte brut
*/
async function extractPositions(pdfPath) {
console.log('\n📄 Extraction des positions des placeholders\n');
console.log(`Fichier: ${pdfPath}\n`);
try {
// Lire le fichier PDF
const pdfBuffer = fs.readFileSync(pdfPath);
const pdfText = pdfBuffer.toString('binary');
// Chercher les pages (marqueurs de page dans le PDF)
const pages = [];
let currentPage = 1;
let pageStart = 0;
// Chercher les marqueurs "endobj" qui marquent la fin des objets de page
const pageMarkerRegex = /\/Type\s*\/Page(?!s)/g;
let match;
while ((match = pageMarkerRegex.exec(pdfText)) !== null) {
pages.push({
num: currentPage,
startOffset: pageStart,
endOffset: match.index,
});
pageStart = match.index;
currentPage++;
}
pages.push({
num: currentPage,
startOffset: pageStart,
endOffset: pdfText.length,
});
console.log(`Total pages détectées: ${pages.length}\n`);
// Chercher les placeholders avec les patterns
const results = [];
for (const pattern of PATTERNS) {
pattern.lastIndex = 0;
let matchResult;
while ((matchResult = pattern.exec(pdfText)) !== null) {
const textFound = matchResult[0];
const position = matchResult.index;
// Déterminer la page
let pageNum = 1;
for (const page of pages) {
if (position >= page.startOffset && position <= page.endOffset) {
pageNum = page.num;
break;
}
}
// Essayer d'extraire les coordonnées si c'est un placeholder formaté
let role = 'Inconnu';
let width = 150;
let height = 60;
if (textFound.includes('Employeur')) {
role = 'Employeur';
} else if (textFound.includes('Employé') || textFound.includes('Salarié')) {
role = 'Salarié';
}
// Extraire les dimensions si présentes dans le placeholder
const dimensionsMatch = textFound.match(/height=(\d+);width=(\d+)/i);
if (dimensionsMatch) {
height = parseInt(dimensionsMatch[1]);
width = parseInt(dimensionsMatch[2]);
}
results.push({
text: textFound,
page: pageNum,
offsetInPDF: position,
role,
width,
height,
});
}
}
if (results.length === 0) {
console.log('❌ Aucun placeholder trouvé dans le PDF!\n');
console.log('Cherchez-vous les patterns corrects?\n');
console.log('Patterns en cours de recherche:');
PATTERNS.forEach(p => console.log(` - ${p}`));
console.log('\n');
return;
}
// Afficher les résultats
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('📍 PLACEHOLDERS TROUVÉS');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
results.forEach((result, idx) => {
console.log(`${idx + 1}. ${result.text}`);
console.log(` Rôle: ${result.role}`);
console.log(` Page: ${result.page}`);
console.log(` Dimensions: ${result.width} × ${result.height} mm`);
console.log(` Position dans PDF: offset ${result.offsetInPDF}\n`);
});
// Générer les positions pour test-odentas-sign.js
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('📋 POSITIONS POUR test-odentas-sign.js');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
console.log('positions: [');
// Grouper par rôle
const byRole = {};
results.forEach(r => {
if (!byRole[r.role]) byRole[r.role] = [];
byRole[r.role].push(r);
});
let xPos = 20;
Object.entries(byRole).forEach(([role, items]) => {
items.forEach(item => {
console.log(` {`);
console.log(` role: '${item.role}',`);
console.log(` page: ${item.page},`);
console.log(` x: ${xPos},`);
console.log(` y: 260,`);
console.log(` w: ${item.width},`);
console.log(` h: ${item.height},`);
console.log(` kind: 'signature',`);
console.log(` label: '${item.text}',`);
console.log(` },`);
xPos += item.width + 20;
});
});
console.log(']');
// Sauvegarder en JSON
const outputPath = pdfPath.replace('.pdf', '-positions.json');
const positionsData = results.map(r => ({
role: r.role,
page: r.page,
x: 20, // À ajuster manuellement
y: 260, // À ajuster manuellement
w: r.width,
h: r.height,
kind: 'signature',
label: r.text,
}));
fs.writeFileSync(outputPath, JSON.stringify(positionsData, null, 2));
console.log(`\n💾 Sauvegardé: ${outputPath}\n`);
} catch (error) {
console.error('❌ Erreur:', error.message);
process.exit(1);
}
}
// Point d'entrée
const pdfPath = process.argv[2] || path.join(__dirname, 'test-contrat.pdf');
if (!fs.existsSync(pdfPath)) {
console.error(`\n❌ Fichier PDF non trouvé: ${pdfPath}\n`);
process.exit(1);
}
extractPositions(pdfPath).catch(console.error);

View file

@ -0,0 +1,116 @@
# Lambda PDF Converter pour Odentas Sign
## Description
Cette Lambda est déclenchée automatiquement par S3 quand un PDF est uploadé dans le bucket `odentas-sign` (prefix `source/`).
Elle convertit le PDF en images JPEG via Cloudinary et stocke les images dans `odentas-docs` pour être utilisées sur les pages de signature.
## Déploiement
### 1. Installation des dépendances
```bash
cd lambda-pdf-converter
npm install
```
### 2. Créer le ZIP de déploiement
```bash
zip -r lambda-pdf-converter.zip . -x "*.git*" -x "README.md"
```
### 3. Créer la Lambda dans AWS
1. Allez dans AWS Lambda Console
2. Créer une nouvelle fonction :
- **Nom** : `odentas-sign-pdf-converter`
- **Runtime** : Node.js 20.x
- **Architecture** : x86_64
- **Rôle d'exécution** : Créer un nouveau rôle avec les permissions de base
3. Uploader le ZIP `lambda-pdf-converter.zip`
### 4. Configuration de la Lambda
#### Variables d'environnement
```
CLOUDINARY_CLOUD_NAME=duecox5va
CLOUDINARY_API_KEY=265234555873541
CLOUDINARY_API_SECRET=DS5k0Zo2LxDkE5KmA3nFsT3bL1M
AWS_REGION=eu-west-3
SOURCE_BUCKET=odentas-sign
DEST_BUCKET=odentas-docs
```
#### Configuration générale
- **Mémoire** : 512 MB (ou plus selon la taille des PDF)
- **Timeout** : 5 minutes (300 secondes)
- **Retry** : 0 (pas de retry automatique)
#### Permissions IAM
Ajouter ces permissions au rôle de la Lambda :
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject"
],
"Resource": "arn:aws:s3:::odentas-sign/*"
},
{
"Effect": "Allow",
"Action": [
"s3:PutObject"
],
"Resource": "arn:aws:s3:::odentas-docs/odentas-sign-images/*"
}
]
}
```
### 5. Configurer le trigger S3
1. Dans la Lambda, ajouter un trigger "S3"
2. Configuration :
- **Bucket** : `odentas-sign`
- **Event type** : `PUT` (ObjectCreated:Put)
- **Prefix** : `source/`
- **Suffix** : `.pdf`
### 6. Tester
1. Uploadez un PDF de test dans `s3://odentas-sign/source/test/TEST-123456789.pdf`
2. Vérifiez les logs CloudWatch de la Lambda
3. Vérifiez que les images sont créées dans `s3://odentas-docs/odentas-sign-images/TEST/page-*.jpg`
## Architecture
```
Upload PDF
s3://odentas-sign/source/{folder}/{requestId}-{timestamp}.pdf
Event S3 ObjectCreated
Lambda odentas-sign-pdf-converter
Cloudinary (conversion PDF → JPEG)
s3://odentas-docs/odentas-sign-images/{requestId}/page-{num}.jpg
```
## Notes
- La Lambda extrait le `requestId` depuis le nom du fichier (partie avant le premier tiret)
- Format attendu : `{requestId}-{timestamp}.pdf`
- Exemple : `4fc9bdf9-eacc-4eed-b713-da40a095c5e7-1234567890.pdf` → requestId = `4fc9bdf9`
- Les images sont stockées avec metadata pour traçabilité

View file

@ -0,0 +1,14 @@
#!/bin/bash
# Script pour créer une Lambda Layer avec Poppler depuis un container Amazon Linux
docker run --rm -v "$PWD":/var/task public.ecr.aws/lambda/nodejs:20 bash -c "
yum install -y poppler-utils
mkdir -p /tmp/layer/bin
cp /usr/bin/pdftoppm /tmp/layer/bin/
cp /usr/bin/pdfinfo /tmp/layer/bin/
# Copier les libs nécessaires
mkdir -p /tmp/layer/lib
ldd /usr/bin/pdftoppm | grep '=>' | awk '{print \$3}' | xargs -I {} cp {} /tmp/layer/lib/ 2>/dev/null || true
cd /tmp/layer
zip -r /var/task/poppler-layer.zip .
"

49
lambda-pdf-converter/deploy.sh Executable file
View file

@ -0,0 +1,49 @@
#!/bin/bash
# Script de déploiement de la Lambda PDF Converter
set -e
echo "🚀 Déploiement de la Lambda odentas-sign-pdf-converter"
echo ""
cd lambda-pdf-converter
# 1. Installation des dépendances
echo "📦 Installation des dépendances..."
npm install --production
# 2. Création du ZIP
echo "📦 Création du package ZIP..."
rm -f lambda-pdf-converter.zip
zip -r lambda-pdf-converter.zip . -x "*.git*" -x "README.md" -x "deploy.sh"
echo ""
echo "✅ Package créé: lambda-pdf-converter.zip"
echo ""
echo "📋 Prochaines étapes:"
echo ""
echo "1. Créer la Lambda dans AWS Console:"
echo " - Nom: odentas-sign-pdf-converter"
echo " - Runtime: Node.js 20.x"
echo " - Mémoire: 512 MB"
echo " - Timeout: 5 minutes"
echo ""
echo "2. Uploader le fichier lambda-pdf-converter.zip"
echo ""
echo "3. Configurer les variables d'environnement:"
echo " CLOUDINARY_CLOUD_NAME=duecox5va"
echo " CLOUDINARY_API_KEY=265234555873541"
echo " CLOUDINARY_API_SECRET=DS5k0Zo2LxDkE5KmA3nFsT3bL1M"
echo " AWS_REGION=eu-west-3"
echo " SOURCE_BUCKET=odentas-sign"
echo " DEST_BUCKET=odentas-docs"
echo ""
echo "4. Ajouter le trigger S3:"
echo " - Bucket: odentas-sign"
echo " - Event: PUT"
echo " - Prefix: source/"
echo " - Suffix: .pdf"
echo ""
echo "5. Ajouter les permissions IAM au rôle de la Lambda"
echo ""

View file

@ -0,0 +1,170 @@
/**
* Lambda déclenchée par S3 ObjectCreated
* Convertit automatiquement les PDF en images JPEG avec pdftoppm
* et les stocke dans S3 pour les pages de signature
*/
const { S3Client, GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
const { spawn } = require('child_process');
const fs = require('fs').promises;
const path = require('path');
const { pipeline } = require('stream/promises');
// Client S3
const s3Client = new S3Client({ region: process.env.AWS_REGION || 'eu-west-3' });
const SOURCE_BUCKET = process.env.SOURCE_BUCKET || 'odentas-sign';
const DEST_BUCKET = process.env.DEST_BUCKET || 'odentas-docs';
/**
* Stream un objet S3 vers un fichier local
*/
async function streamToFile(readable, filePath) {
const fileHandle = await fs.open(filePath, 'w');
const writable = fileHandle.createWriteStream();
await pipeline(readable, writable);
}
/**
* Convertit un PDF en images JPEG avec pdftoppm
*/
async function convertPdfToImages(pdfPath, requestId, outputDir) {
const outputPrefix = path.join(outputDir, 'page');
const args = [
'-jpeg',
'-jpegopt', 'quality=90',
'-r', '150', // 150 DPI pour bonne qualité
pdfPath,
outputPrefix
];
return new Promise((resolve, reject) => {
const proc = spawn('pdftoppm', args);
let stderr = '';
proc.stderr.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`pdftoppm exit ${code}: ${stderr}`));
}
});
});
}
/**
* Handler principal
*/
exports.handler = async (event) => {
console.log('[Lambda] Event reçu:', JSON.stringify(event, null, 2));
try {
// Traiter chaque record S3
for (const record of event.Records || []) {
const bucket = record.s3.bucket.name;
const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' '));
console.log(`[Lambda] Fichier détecté: s3://${bucket}/${key}`);
if (!key.toLowerCase().endsWith('.pdf')) {
console.log('[Lambda] Ignoré (pas un PDF)');
continue;
}
// Extraire le requestId depuis la clé S3
// Format attendu: source/{folder}/{requestId}-{timestamp}.pdf
const keyParts = key.split('/');
const filename = keyParts[keyParts.length - 1];
const requestId = filename.split('-')[0];
if (!requestId) {
throw new Error(`Impossible d'extraire requestId depuis la clé: ${key}`);
}
console.log(`[Lambda] Request ID extrait: ${requestId}`);
const tmpDir = '/tmp';
const pdfPath = path.join(tmpDir, `${requestId}.pdf`);
// 1. Télécharger le PDF depuis S3
console.log('[Lambda] Téléchargement du PDF depuis S3...');
const getResponse = await s3Client.send(
new GetObjectCommand({
Bucket: bucket,
Key: key,
})
);
await streamToFile(getResponse.Body, pdfPath);
const stats = await fs.stat(pdfPath);
console.log(`[Lambda] PDF téléchargé: ${stats.size} bytes`);
// 2. Convertir avec pdftoppm → génère /tmp/page-1.jpg, /tmp/page-2.jpg ...
console.log('[Lambda] Conversion PDF → JPEG avec pdftoppm...');
await convertPdfToImages(pdfPath, requestId, tmpDir);
// 3. Lister les images générées
const files = await fs.readdir(tmpDir);
const pageFiles = files
.filter(f => f.startsWith('page-') && f.endsWith('.jpg'))
.sort((a, b) => {
const numA = parseInt(a.match(/page-(\d+)/)[1], 10);
const numB = parseInt(b.match(/page-(\d+)/)[1], 10);
return numA - numB;
});
console.log(`[Lambda] ${pageFiles.length} page(s) générée(s):`, pageFiles);
if (pageFiles.length === 0) {
throw new Error('Aucune page générée par pdftoppm');
}
// 4. Upload vers S3
const uploadedPages = [];
for (let i = 0; i < pageFiles.length; i++) {
const pageFile = pageFiles[i];
const pageNum = i + 1;
const imagePath = path.join(tmpDir, pageFile);
console.log(`[Lambda] Upload page ${pageNum}: ${pageFile}`);
const imageBuffer = await fs.readFile(imagePath);
const s3Key = `odentas-sign-images/${requestId}/page-${pageNum}.jpg`;
await s3Client.send(
new PutObjectCommand({
Bucket: DEST_BUCKET,
Key: s3Key,
Body: imageBuffer,
ContentType: 'image/jpeg',
CacheControl: 'public, max-age=31536000', // 1 an
})
);
console.log(`[Lambda] Page ${pageNum} stockée sur S3 → ${s3Key}`);
uploadedPages.push(s3Key);
// Supprimer le fichier temporaire
await fs.unlink(imagePath);
}
// 5. Cleanup du PDF
await fs.unlink(pdfPath);
console.log(`[Lambda] ✅ Conversion terminée: ${uploadedPages.length} page(s) pour ${requestId}`);
}
return {
statusCode: 200,
body: JSON.stringify({ message: 'Conversion réussie' }),
};
} catch (error) {
console.error('[Lambda] ❌ Erreur:', error);
throw error;
}
};

View file

@ -0,0 +1,203 @@
/**
* Lambda déclenchée par S3 ObjectCreated
* Convertit automatiquement les PDF en images JPEG avec pdfjs-dist + canvas
* et les stocke dans S3 pour les pages de signature
*/
const { S3Client, GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
const { getDocument } = require('pdfjs-dist/legacy/build/pdf.js');
const { createCanvas } = require('canvas');
const sharp = require('sharp');
// Client S3
const s3Client = new S3Client({ region: process.env.AWS_REGION || 'eu-west-3' });
const SOURCE_BUCKET = process.env.SOURCE_BUCKET || 'odentas-sign';
const DEST_BUCKET = process.env.DEST_BUCKET || 'odentas-docs';
/**
* Convertit une page PDF en image JPEG avec Sharp
*/
async function convertPdfPageToJpeg(pdfPage, scale = 2.0) {
const viewport = pdfPage.getViewport({ scale });
const canvas = createCanvas(viewport.width, viewport.height);
const context = canvas.getContext('2d');
await pdfPage.render({
canvasContext: context,
viewport: viewport,
}).promise;
// Convertir le canvas en buffer puis optimiser avec Sharp
const rawImageBuffer = canvas.toBuffer('raw');
return await sharp(rawImageBuffer, {
raw: {
width: Math.floor(viewport.width),
height: Math.floor(viewport.height),
channels: 4, // RGBA
},
})
.jpeg({
quality: 90,
progressive: true,
})
.resize(1400, null, { // Max 1400px de largeur
withoutEnlargement: true,
fit: 'inside',
})
.toBuffer();
}
/**
* Handler principal
*/
exports.handler = async (event) => {
console.log('[Lambda] Event reçu:', JSON.stringify(event, null, 2));
try {
// Récupérer les informations du fichier uploadé
const record = event.Records[0];
const bucket = record.s3.bucket.name;
const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' '));
console.log(`[Lambda] Fichier détecté: s3://${bucket}/${key}`);
// Extraire le requestId depuis la clé S3
// Format attendu: source/{folder}/{requestId}-{timestamp}.pdf
const keyParts = key.split('/');
const filename = keyParts[keyParts.length - 1];
const requestId = filename.split('-')[0]; // Prend la partie avant le premier tiret
if (!requestId) {
throw new Error(`Impossible d'extraire requestId depuis la clé: ${key}`);
}
console.log(`[Lambda] Request ID extrait: ${requestId}`);
// 1. Télécharger le PDF depuis S3
console.log('[Lambda] Téléchargement du PDF depuis S3...');
const getObjectCommand = new GetObjectCommand({
Bucket: bucket,
Key: key,
});
const s3Response = await s3Client.send(getObjectCommand);
const pdfBuffer = Buffer.from(await s3Response.Body.transformToByteArray());
console.log(`[Lambda] PDF téléchargé: ${pdfBuffer.length} bytes`);
// 2. Upload sur Cloudinary pour conversion (upload en tant que PDF, pas en JPG)
console.log('[Lambda] Upload sur Cloudinary pour conversion...');
const uploadResult = await new Promise((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream(
{
folder: `odentas-sign-temp/${requestId}`,
resource_type: 'raw', // 'raw' pour préserver le PDF tel quel
public_id: `pdf-${requestId}`,
transformation: [
{ width: 1400, crop: 'scale' },
{ quality: 90 },
],
},
(error, result) => {
if (error) reject(error);
else resolve(result);
}
);
bufferToStream(pdfBuffer).pipe(uploadStream);
});
console.log('[Lambda] Upload Cloudinary réussi:', uploadResult.secure_url);
console.log('[Lambda] Pages détectées par Cloudinary:', uploadResult.pages);
const baseUrl = uploadResult.secure_url;
// Cloudinary ne retourne pas toujours le nombre de pages correctement
// On va tester chaque page jusqu'à ce qu'on obtienne une erreur 404
const pages = [];
let pageNum = 1;
const maxPages = 100; // Sécurité pour éviter une boucle infinie
console.log('[Lambda] Détection du nombre de pages...');
// 3. Pour chaque page, télécharger depuis Cloudinary et stocker sur S3
while (pageNum <= maxPages) {
// Pour un PDF uploadé en 'raw', on doit forcer le format JPG avec .jpg
const public_id_with_folder = `${uploadResult.folder}/${uploadResult.public_id}`;
const cloudinaryPageUrl = `https://res.cloudinary.com/${process.env.CLOUDINARY_CLOUD_NAME}/image/upload/pg_${pageNum}/w_1400,q_90/${public_id_with_folder}.jpg`;
console.log(`[Lambda] Test page ${pageNum}: Téléchargement depuis Cloudinary...`);
// Télécharger l'image
const imageResponse = await fetch(cloudinaryPageUrl);
// Si 4xx, on a atteint la fin du PDF (404 ou 400 selon Cloudinary)
if (imageResponse.status >= 400 && imageResponse.status < 500) {
console.log(`[Lambda] Page ${pageNum} non trouvée (${imageResponse.status}), fin du PDF`);
break;
}
if (!imageResponse.ok) {
throw new Error(`Erreur téléchargement page ${pageNum}: ${imageResponse.statusText}`);
}
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
// Stocker sur S3
const s3Key = `odentas-sign-images/${requestId}/page-${pageNum}.jpg`;
await s3Client.send(
new PutObjectCommand({
Bucket: DEST_BUCKET,
Key: s3Key,
Body: imageBuffer,
ContentType: 'image/jpeg',
CacheControl: 'public, max-age=31536000',
Metadata: {
'source-pdf': key,
'request-id': requestId,
'page-number': pageNum.toString(),
'converted-at': new Date().toISOString(),
},
})
);
pages.push(s3Key);
console.log(`[Lambda] Page ${pageNum} stockée sur S3 → ${s3Key}`);
pageNum++;
}
const totalPages = pages.length;
// 4. Nettoyer Cloudinary
console.log('[Lambda] Nettoyage Cloudinary...');
try {
await cloudinary.uploader.destroy(`odentas-sign-temp/${requestId}/pdf-${requestId}`);
console.log('[Lambda] Nettoyage Cloudinary terminé');
} catch (cleanupError) {
console.warn('[Lambda] Erreur nettoyage Cloudinary (non bloquant):', cleanupError);
}
console.log(`[Lambda] ✅ Conversion terminée: ${totalPages} page(s) pour ${requestId}`);
return {
statusCode: 200,
body: JSON.stringify({
success: true,
requestId,
totalPages,
message: 'PDF converti avec succès',
}),
};
} catch (error) {
console.error('[Lambda] ❌ Erreur:', error);
return {
statusCode: 500,
body: JSON.stringify({
success: false,
error: error.message,
}),
};
}
};

Binary file not shown.

1654
lambda-pdf-converter/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,9 @@
{
"name": "lambda-pdf-converter",
"version": "1.0.0",
"description": "Lambda pour convertir les PDF en images JPEG via Cloudinary lors de l'upload sur S3",
"main": "index.js",
"dependencies": {
"@aws-sdk/client-s3": "^3.913.0"
}
}

Binary file not shown.

View file

@ -0,0 +1,23 @@
{
"LambdaFunctionConfigurations": [
{
"Id": "odentas-sign-pdf-converter-trigger",
"LambdaFunctionArn": "arn:aws:lambda:eu-west-3:292468105557:function:odentas-sign-pdf-converter",
"Events": ["s3:ObjectCreated:*"],
"Filter": {
"Key": {
"FilterRules": [
{
"Name": "prefix",
"Value": "source/"
},
{
"Name": "suffix",
"Value": ".pdf"
}
]
}
}
}
]
}

View file

@ -0,0 +1,218 @@
import { v2 as cloudinary } from 'cloudinary';
import { S3Client, PutObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
// Configuration Cloudinary
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
secure: true,
});
// Client S3 pour stocker les images converties
const s3Client = new S3Client({
region: process.env.AWS_REGION || 'eu-west-3',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
const S3_BUCKET = process.env.AWS_S3_BUCKET || 'odentas-docs';
interface PageImage {
pageNumber: number;
imageUrl: string;
width: number;
height: number;
}
/**
* Vérifie si les images d'un PDF existent déjà dans S3
*/
async function checkImagesExistInS3(requestId: string): Promise<PageImage[] | null> {
try {
// On essaie de trouver les images existantes
// On commence par vérifier la page 1
const firstPageKey = `odentas-sign-images/${requestId}/page-1.jpg`;
try {
await s3Client.send(new HeadObjectCommand({
Bucket: S3_BUCKET,
Key: firstPageKey,
}));
} catch (error) {
// La première page n'existe pas, donc les images ne sont pas en cache
return null;
}
console.log('[S3 Cache] Images trouvées dans S3, récupération...');
// La page 1 existe, on récupère toutes les pages
const pageImages: PageImage[] = [];
let pageNum = 1;
while (true) {
const s3Key = `odentas-sign-images/${requestId}/page-${pageNum}.jpg`;
try {
// Vérifier si la page existe
await s3Client.send(new HeadObjectCommand({
Bucket: S3_BUCKET,
Key: s3Key,
}));
// Générer l'URL presignée
const command = new GetObjectCommand({
Bucket: S3_BUCKET,
Key: s3Key,
});
const s3Url = await getSignedUrl(s3Client, command, { expiresIn: 86400 });
pageImages.push({
pageNumber: pageNum,
imageUrl: s3Url,
width: 1400,
height: Math.round(1400 * 1.414),
});
pageNum++;
} catch (error) {
// Plus de pages, on sort de la boucle
break;
}
}
console.log(`[S3 Cache] ✅ ${pageImages.length} page(s) récupérées depuis S3 (pas de conversion)`);
return pageImages.length > 0 ? pageImages : null;
} catch (error) {
console.log('[S3 Cache] Pas de cache trouvé, conversion nécessaire');
return null;
}
}
/**
* Convertit un PDF en images JPEG via Cloudinary,
* puis stocke les images sur S3 pour économiser la bande passante Cloudinary
*/
export async function convertPdfToImagesWithCloudinary(
pdfBuffer: Buffer,
requestId: string
): Promise<PageImage[]> {
try {
// 1. Vérifier si les images existent déjà dans S3
const cachedImages = await checkImagesExistInS3(requestId);
if (cachedImages) {
console.log('[S3 Cache] ✅ Utilisation du cache S3, pas de conversion Cloudinary');
return cachedImages;
}
console.log('[Cloudinary] Pas de cache S3, début conversion...');
console.log('[Cloudinary] Début upload PDF, taille:', pdfBuffer.length);
// 1. Upload le PDF sur Cloudinary pour conversion
const uploadResult = await new Promise<any>((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream(
{
folder: `odentas-sign-temp/${requestId}`,
resource_type: 'image',
format: 'jpg',
public_id: `pdf-${requestId}`,
transformation: [
{ width: 1400, crop: 'scale' },
{ quality: 90 },
],
},
(error, result) => {
if (error) reject(error);
else resolve(result);
}
);
uploadStream.end(pdfBuffer);
});
console.log('[Cloudinary] Upload réussi, conversion en cours...');
const baseUrl = uploadResult.secure_url;
const pages = uploadResult.pages || 1;
const pageImages: PageImage[] = [];
// 2. Pour chaque page, télécharger l'image depuis Cloudinary et la stocker sur S3
for (let pageNum = 1; pageNum <= pages; pageNum++) {
// URL Cloudinary de la page
const cloudinaryPageUrl = baseUrl.replace(
'/upload/',
`/upload/pg_${pageNum}/w_1400,q_90/`
);
console.log(`[Cloudinary] Téléchargement page ${pageNum}/${pages} depuis Cloudinary...`);
// Télécharger l'image depuis Cloudinary
const imageResponse = await fetch(cloudinaryPageUrl);
if (!imageResponse.ok) {
throw new Error(`Erreur téléchargement page ${pageNum}: ${imageResponse.statusText}`);
}
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
// 3. Stocker l'image sur S3
const s3Key = `odentas-sign-images/${requestId}/page-${pageNum}.jpg`;
await s3Client.send(
new PutObjectCommand({
Bucket: S3_BUCKET,
Key: s3Key,
Body: imageBuffer,
ContentType: 'image/jpeg',
CacheControl: 'public, max-age=31536000', // Cache 1 an
})
);
// Générer une URL presignée S3 (valide 24h, renouvelée à chaque accès)
const command = new GetObjectCommand({
Bucket: S3_BUCKET,
Key: s3Key,
});
const s3Url = await getSignedUrl(s3Client, command, { expiresIn: 86400 }); // 24h
pageImages.push({
pageNumber: pageNum,
imageUrl: s3Url,
width: 1400,
height: Math.round(1400 * 1.414), // Ratio A4
});
console.log(`[Cloudinary → S3] Page ${pageNum}/${pages} stockée sur S3: ${s3Key}`);
}
// 4. Nettoyer Cloudinary (supprimer le PDF temporaire)
try {
await cloudinary.uploader.destroy(`odentas-sign-temp/${requestId}/pdf-${requestId}`);
console.log('[Cloudinary] Nettoyage temporaire effectué');
} catch (cleanupError) {
console.warn('[Cloudinary] Erreur nettoyage (non bloquant):', cleanupError);
}
console.log(`[Cloudinary → S3] ✅ ${pages} page(s) converties et stockées sur S3`);
return pageImages;
} catch (error) {
console.error('[Cloudinary] Erreur:', error);
throw new Error(
`Erreur Cloudinary: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
);
}
}
/**
* Supprime un PDF et ses pages de Cloudinary
*/
export async function deleteFromCloudinary(requestId: string): Promise<void> {
try {
await cloudinary.api.delete_resources_by_prefix(`odentas-sign/${requestId}`);
console.log(`[Cloudinary] PDF ${requestId} supprimé`);
} catch (error) {
console.error('[Cloudinary] Erreur suppression:', error);
}
}

View file

@ -0,0 +1,52 @@
import { pdf } from 'pdf-to-img';
interface PageImage {
pageNumber: number;
imageBase64: string;
width: number;
height: number;
}
/**
* Convertit un PDF (buffer) en images JPEG
* Solution ultra simple avec pdf-to-img
*/
export async function convertPdfToJpegImages(
pdfBuffer: Buffer,
targetWidth: number = 1400
): Promise<PageImage[]> {
try {
console.log('[PDF Converter] Début conversion, taille buffer:', pdfBuffer.length);
const pages: PageImage[] = [];
// Convertir le PDF en images
const document = await pdf(pdfBuffer, {
scale: 2.0, // Haute résolution
});
let pageNum = 1;
for await (const page of document) {
// page est déjà un Buffer PNG
const imageBase64 = `data:image/png;base64,${page.toString('base64')}`;
pages.push({
pageNumber: pageNum,
imageBase64,
width: targetWidth,
height: Math.round(targetWidth * 1.414), // Ratio A4 approximatif
});
console.log(`[PDF Converter] Page ${pageNum} convertie`);
pageNum++;
}
console.log(`[PDF Converter] ${pages.length} page(s) converties avec succès`);
return pages;
} catch (error) {
console.error('[PDF Converter] Erreur:', error);
throw new Error(`Erreur de conversion PDF: ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
}
}

View file

@ -0,0 +1,126 @@
// Utilities to extract DocuSeal-style signature placeholders from PDFs
// and estimate reasonable positions when exact text coordinates are unavailable.
export type PlaceholderMatch = {
fullMatch: string;
label: string;
role: string;
type: string;
width: number; // mm
height: number; // mm
startIndex: number;
endIndex: number;
};
export type EstimatedPosition = {
role: string;
label: string;
page: number; // 1-indexed
x: number; // mm from left
y: number; // mm from top
width: number; // mm
height: number; // mm
};
const PLACEHOLDER_REGEX = /\{\{([^;]+);role=([^;]+);type=([^;]+);height=(\d+);width=(\d+)\}\}/g;
/**
* Count PDF pages by scanning for '/Type /Page' markers in raw bytes.
* This is heuristic but robust enough for most PDFs without full parsing.
*/
export function countPdfPagesFromBytes(bytes: Uint8Array | Buffer): number {
try {
const text = bufferToLatin1String(bytes);
// Count '/Type /Page' but not '/Type /Pages'
const matches = text.match(/\/Type\s*\/Page(?!s)\b/g);
if (matches && matches.length > 0) return matches.length;
} catch {
// ignore
}
// Default to 1 page if unknown
return 1;
}
/**
* Extract placeholders from PDF bytes by regex scanning the raw stream text.
*/
export function extractPlaceholdersFromPdfBuffer(bytes: Uint8Array | Buffer): PlaceholderMatch[] {
const text = bufferToLatin1String(bytes);
const placeholders: PlaceholderMatch[] = [];
PLACEHOLDER_REGEX.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = PLACEHOLDER_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], 10),
width: parseInt(match[5], 10),
startIndex: match.index,
endIndex: match.index + match[0].length,
});
}
return placeholders;
}
/**
* Estimate reasonable positions (in mm) for placeholders when exact coordinates are unknown.
* Assumes A4 portrait (210 x 297mm). Places fields near the bottom margin, left/right by role.
*/
export function estimatePositionsFromPlaceholders(
placeholders: PlaceholderMatch[],
pageCount: number
): EstimatedPosition[] {
const A4_WIDTH_MM = 210;
const A4_HEIGHT_MM = 297;
const MARGIN_X_MM = 20;
const MARGIN_BOTTOM_MM = 30;
// Prefer placing on the last page by default
const defaultPage = Math.max(1, pageCount);
return placeholders.map((ph) => {
// Les placeholders portent déjà les dimensions attendues du cadre
// de signature (en millimètres), on les utilise telles quelles.
const width = Math.max(20, ph.width || 150); // mm
const height = Math.max(10, ph.height || 60); // mm
// Default Y: bottom area
const y = A4_HEIGHT_MM - MARGIN_BOTTOM_MM - height;
// Role-based horizontal placement: employer left, employee right
const roleLc = ph.role.toLowerCase();
const isEmployee = roleLc.includes('salari') || roleLc.includes('employé') || roleLc.includes('employe');
const x = isEmployee
? Math.max(MARGIN_X_MM, A4_WIDTH_MM - MARGIN_X_MM - width)
: MARGIN_X_MM;
return {
role: ph.role,
label: ph.label,
page: defaultPage,
x,
y,
width,
height,
};
});
}
function bufferToLatin1String(bytes: Uint8Array | Buffer): string {
if (typeof Buffer !== 'undefined' && (bytes as Buffer).toString) {
return (bytes as Buffer).toString('latin1');
}
// Fallback for environments without Node Buffer
let result = '';
const chunk = 8192;
for (let i = 0; i < bytes.length; i += chunk) {
const slice = bytes.slice(i, i + chunk);
result += Array.from(slice as any, (b: number) => String.fromCharCode(b)).join('');
}
return result;
}

View file

@ -11,6 +11,12 @@ const nextConfig = {
// Configuration pour optimiser les chunks et éviter les erreurs de modules Supabase
webpack: (config, { dev, isServer }) => {
if (!isServer) {
// Ignorer le module 'canvas' côté client (optionnel pour pdfjs-dist)
config.resolve.fallback = {
...config.resolve.fallback,
canvas: false,
};
// Optimiser les chunks pour éviter les problèmes avec Supabase
config.optimization.splitChunks = {
...config.optimization.splitChunks,

874
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -31,6 +31,7 @@
"axios": "^1.12.2",
"bcryptjs": "^3.0.2",
"canvas-confetti": "^1.9.4",
"cloudinary": "^2.8.0",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dotenv": "^17.2.2",
@ -43,13 +44,16 @@
"nprogress": "^0.2.0",
"pdf-lib": "^1.17.1",
"pdf-parse": "^2.4.5",
"pdfjs-dist": "^3.11.174",
"pdf-to-img": "^5.0.0",
"pdf2pic": "^3.2.0",
"posthog-js": "^1.275.1",
"posthog-node": "^5.9.5",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.62.0",
"react-pdf": "^10.2.0",
"sharp": "^0.34.4",
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.0",
"use-debounce": "^10.0.6"

View file

@ -0,0 +1 @@
404: Not Found

View file

@ -1,26 +1,26 @@
{
"success": true,
"request": {
"id": "0d14754a-740b-42e0-9766-60582e116d09",
"ref": "REAL-1761586268897",
"id": "89a4786c-07e4-4dfa-8fdd-3089dbef5996",
"ref": "REAL-1761590226249",
"title": "Contrat CDDU - contrat_cddu_LYXHX3GI_240V001",
"status": "pending",
"created_at": "2025-10-27T17:31:09.550025+00:00"
"created_at": "2025-10-27T18:37:09.227226+00:00"
},
"signers": [
{
"signerId": "12430034-e696-428a-876e-ba4d35b1ff2c",
"signerId": "43a3a850-74e8-4281-b1ab-e61000304201",
"role": "Employeur",
"name": "Odentas Paie",
"email": "paie@odentas.fr",
"signatureUrl": "https://espace-paie.odentas.fr/signer/0d14754a-740b-42e0-9766-60582e116d09/12430034-e696-428a-876e-ba4d35b1ff2c"
"signatureUrl": "https://espace-paie.odentas.fr/signer/89a4786c-07e4-4dfa-8fdd-3089dbef5996/43a3a850-74e8-4281-b1ab-e61000304201"
},
{
"signerId": "1c8914ad-4cfa-40e2-870b-b6e269ba29f3",
"signerId": "d2646860-3d0f-42d6-abdc-c6084e870088",
"role": "Salarié",
"name": "Renaud Breviere",
"email": "renaud.breviere@gmail.com",
"signatureUrl": "https://espace-paie.odentas.fr/signer/0d14754a-740b-42e0-9766-60582e116d09/1c8914ad-4cfa-40e2-870b-b6e269ba29f3"
"signatureUrl": "https://espace-paie.odentas.fr/signer/89a4786c-07e4-4dfa-8fdd-3089dbef5996/d2646860-3d0f-42d6-abdc-c6084e870088"
}
]
}

View file

@ -10,7 +10,16 @@ echo ""
# Créer une nouvelle demande de signature
echo "📝 1. Création d'une demande de signature..."
RESPONSE=$(node create-real-signature.js)
# Utiliser le PDF de test existant
PDF_FILE="contrat_cddu_LYXHX3GI_240V001.pdf"
if [ ! -f "$PDF_FILE" ]; then
echo "❌ Erreur: Fichier PDF non trouvé: $PDF_FILE"
exit 1
fi
RESPONSE=$(node create-real-signature.js "$PDF_FILE")
echo "$RESPONSE"
# Extraire le request_id et les signer IDs du JSON

View file

@ -1,26 +1,26 @@
{
"success": true,
"request": {
"id": "75b4408d-1bbd-464f-a9ea-2b4e5075a817",
"ref": "TEST-1761582838435",
"id": "4fc9bdf9-eacc-4eed-b713-da40a095c5e7",
"ref": "TEST-1761595009432",
"title": "Contrat CDDU - Test Local",
"status": "pending",
"created_at": "2025-10-27T16:34:07.361187+00:00"
"created_at": "2025-10-27T19:56:51.428927+00:00"
},
"signers": [
{
"signerId": "95c4ccdc-1a26-4426-a56f-653758159b54",
"signerId": "8f0c03c2-0891-4696-9f03-1d5d562cfb9e",
"role": "Employeur",
"name": "Odentas Paie",
"email": "paie@odentas.fr",
"signatureUrl": "https://espace-paie.odentas.fr/signer/75b4408d-1bbd-464f-a9ea-2b4e5075a817/95c4ccdc-1a26-4426-a56f-653758159b54"
"signatureUrl": "https://espace-paie.odentas.fr/signer/4fc9bdf9-eacc-4eed-b713-da40a095c5e7/8f0c03c2-0891-4696-9f03-1d5d562cfb9e"
},
{
"signerId": "d481f070-2ac6-4f82-aff3-862783904d5d",
"signerId": "1f162f81-a235-4079-b499-aa9dadbe9eff",
"role": "Salarié",
"name": "Renaud Breviere",
"email": "renaud.breviere@gmail.com",
"signatureUrl": "https://espace-paie.odentas.fr/signer/75b4408d-1bbd-464f-a9ea-2b4e5075a817/d481f070-2ac6-4f82-aff3-862783904d5d"
"signatureUrl": "https://espace-paie.odentas.fr/signer/4fc9bdf9-eacc-4eed-b713-da40a095c5e7/1f162f81-a235-4079-b499-aa9dadbe9eff"
}
]
}

View file

@ -82,23 +82,23 @@ async function main() {
positions: [
{
role: 'Employeur',
page: 1,
x: 100,
y: 680,
w: 200,
page: 3,
x: 20,
y: 260,
w: 150,
h: 60,
kind: 'signature',
label: 'Signature Employeur',
},
{
role: 'Salarié',
page: 1,
x: 350,
y: 680,
w: 200,
page: 3,
x: 180,
y: 260,
w: 150,
h: 60,
kind: 'signature',
label: 'Signature Salarié',
label: 'Signature Employé',
},
],
};