import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'; import path from 'path'; import fs from 'fs'; const MONTANT_MOYEN = 6.8; // ─── Palette ─────────────────────────────────────────────── const TEAL = { r: 43 / 255, g: 147 / 255, b: 157 / 255 }; const DARK = { r: 0.2, g: 0.2, b: 0.2 }; const MID = { r: 0.5, g: 0.5, b: 0.5 }; const LINE = { r: 0.82, g: 0.82, b: 0.82 }; const LIGHT_BG = { r: 0.96, g: 0.96, b: 0.96 }; const TEAL_BG = { r: 235 / 255, g: 248 / 255, b: 249 / 255 }; export interface ParticipationData { adherentNom: string; adherentPrenom: string; adherentAdresse: string; destinataireEmail: string; destinataireNom: string; dateTrajet: Date; adresseDepart: string; adresseArrivee: string; montant?: number; complement?: string; participationId?: string; } // ─── Helpers ─────────────────────────────────────────────── async function loadLogoAsPng(logoName: string, maxSize: number): Promise { try { const sharp = (await import('sharp')).default; const logoPath = path.join(process.cwd(), 'public', logoName); if (!fs.existsSync(logoPath)) return null; return sharp(logoPath) .resize(maxSize, maxSize, { fit: 'inside' }) .png() .toBuffer(); } catch { return null; } } function wrapText(text: string, maxWidth: number, measureFn: (t: string) => number): string[] { const words = text.split(/\s+/); const lines: string[] = []; let currentLine = ''; for (const word of words) { const testLine = currentLine ? `${currentLine} ${word}` : word; if (measureFn(testLine) <= maxWidth) { currentLine = testLine; } else { if (currentLine) lines.push(currentLine); currentLine = word; } } if (currentLine) lines.push(currentLine); return lines; } // ─── Génération du PDF ───────────────────────────────────── export async function generateParticipationPDF( data: ParticipationData, outputPath: string ): Promise { const pdfDoc = await PDFDocument.create(); const font = await pdfDoc.embedFont(StandardFonts.Helvetica); const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); const page = pdfDoc.addPage([595, 842]); // A4 const { width } = page.getSize(); const M = 50; // marge const R = width - M; // bord droit (545) const CW = width - 2 * M; // largeur utile (495) const teal = rgb(TEAL.r, TEAL.g, TEAL.b); const dark = rgb(DARK.r, DARK.g, DARK.b); const mid = rgb(MID.r, MID.g, MID.b); // Raccourcis const measure = (text: string, size: number, f = font) => f.widthOfTextAtSize(text, size); const drawRight = ( text: string, x: number, y: number, size: number, f = font, color = dark ) => { page.drawText(text, { x: x - f.widthOfTextAtSize(text, size), y, size, font: f, color }); }; const hLine = (y: number, x = M, w = CW) => { page.drawRectangle({ x, y, width: w, height: 1, color: rgb(LINE.r, LINE.g, LINE.b), }); }; // Données formatées const montant = data.montant ?? MONTANT_MOYEN; const montantStr = montant.toFixed(2).replace('.', ',') + ' \u20AC'; const refNum = data.participationId ? `PART-${data.participationId.slice(-8).toUpperCase()}` : 'PART-XXXXXXXX'; const dateEmission = data.dateTrajet.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', }); const dateTrajetLong = data.dateTrajet.toLocaleDateString('fr-FR', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric', }); let y = 800; // ════════════════════════════════════════════════════════ // EN-TÊTE // ════════════════════════════════════════════════════════ // Logo const logoPng = await loadLogoAsPng('logo.svg', 48); const logoS = 48; if (logoPng) { try { const img = await pdfDoc.embedPng(logoPng); const w = img.width * (logoS / img.height); page.drawImage(img, { x: M, y: y - logoS, width: Math.min(w, logoS), height: logoS, }); } catch { /* fallback silencieux */ } } // Infos association (à droite du logo) const hx = M + logoS + 14; page.drawText('Association MAD', { x: hx, y: y - 15, size: 13, font: fontBold, color: teal, }); page.drawText('Adresse postale', { x: hx, y: y - 29, size: 9, font, color: mid, }); page.drawText('Ville, Code postal', { x: hx, y: y - 41, size: 9, font, color: mid, }); // Référence document (aligné à droite) drawRight('Participation financière', R, y - 12, 9, font, mid); drawRight(refNum, R, y - 28, 11, fontBold, teal); drawRight(`\u00C9mise le ${dateEmission}`, R, y - 44, 9, font, mid); // Séparateur y -= 62; hLine(y); // ════════════════════════════════════════════════════════ // TITRE // ════════════════════════════════════════════════════════ y -= 30; page.drawText('Participation financière au transport', { x: M, y, size: 17, font: fontBold, color: teal, }); y -= 16; page.drawText( "Document établi dans le cadre de l'accompagnement au transport par l'Association MAD.", { x: M, y, size: 9, font, color: mid }, ); // ════════════════════════════════════════════════════════ // BLOCS D'INFORMATION // ════════════════════════════════════════════════════════ y -= 56; const gap = 24; const bw = (CW - gap) / 2; // ~235px par bloc const bx2 = M + bw + gap; // x du bloc droit // Helper : titre de section avec soulignement teal const sectionTitle = (label: string, x: number, yy: number) => { page.drawText(label, { x, y: yy, size: 8, font: fontBold, color: teal }); page.drawRectangle({ x, y: yy - 3, width: fontBold.widthOfTextAtSize(label, 8), height: 1.5, color: teal, }); }; // ── Bloc gauche : FACTURÉ À ── const blockTopY = y; sectionTitle('FACTURÉ À', M, blockTopY); let ly = blockTopY - 20; const destLines = wrapText(data.destinataireNom, bw, (t) => measure(t, 10, fontBold)); for (const line of destLines.slice(0, 2)) { page.drawText(line, { x: M, y: ly, size: 10, font: fontBold, color: dark }); ly -= 14; } page.drawText(data.destinataireEmail, { x: M, y: ly, size: 9, font, color: mid, }); ly -= 14; // ── Bloc droit : DÉTAILS DU TRAJET ── sectionTitle('DÉTAILS DU TRAJET', bx2, blockTopY); let ry = blockTopY - 20; const infos: { label: string; value: string }[] = [ { label: 'Date', value: dateTrajetLong }, { label: 'Adhérent', value: `${data.adherentPrenom} ${data.adherentNom}` }, ]; for (const item of infos) { const prefix = `${item.label} : `; const prefixW = measure(prefix, 8, fontBold); page.drawText(prefix, { x: bx2, y: ry, size: 8, font: fontBold, color: dark, }); const valLines = wrapText(item.value, bw - prefixW, (t) => measure(t, 9)); for (let i = 0; i < Math.min(valLines.length, 2); i++) { page.drawText(valLines[i], { x: bx2 + prefixW, y: ry, size: 9, font, color: dark, }); if (i < valLines.length - 1) ry -= 12; } ry -= 16; } // ════════════════════════════════════════════════════════ // TABLEAU // ════════════════════════════════════════════════════════ y = Math.min(ly, ry) - 56; // Positions des colonnes const cDesig = M + 10; const cQte = M + 290; const cPu = M + 370; // cMontant : aligné à droite sur R - 8 // ── En-tête du tableau ── const thH = 28; // Ligne d'accent teal en haut page.drawRectangle({ x: M, y: y + thH - 2, width: CW, height: 2, color: teal, }); // Fond gris clair page.drawRectangle({ x: M, y: y, width: CW, height: thH - 2, color: rgb(LIGHT_BG.r, LIGHT_BG.g, LIGHT_BG.b), }); const thY = y + 8; page.drawText('DÉSIGNATION', { x: cDesig, y: thY, size: 8, font: fontBold, color: dark, }); // QTÉ centré const qteLabel = 'QTÉ'; const qteLabelW = measure(qteLabel, 8, fontBold); page.drawText(qteLabel, { x: cQte + 20 - qteLabelW / 2, y: thY, size: 8, font: fontBold, color: dark, }); page.drawText('P.U.', { x: cPu, y: thY, size: 8, font: fontBold, color: dark, }); drawRight('MONTANT', R - 8, thY, 8, fontBold, dark); // Ligne sous en-tête hLine(y); // ── Ligne article ── y -= 18; const articleTitleY = y; // Titre de l'article page.drawText('Participation au transport', { x: cDesig, y: articleTitleY, size: 10, font: fontBold, color: dark, }); // Adresses complètes (wrappées, sans troncature) const maxDesigW = cQte - cDesig - 20; const depLines = wrapText( `De : ${data.adresseDepart}`, maxDesigW, (t) => measure(t, 8), ); const arrLines = wrapText( `Vers : ${data.adresseArrivee}`, maxDesigW, (t) => measure(t, 8), ); let addrY = articleTitleY - 16; for (const line of depLines.slice(0, 3)) { page.drawText(line, { x: cDesig, y: addrY, size: 8, font, color: mid }); addrY -= 11; } addrY -= 2; // petit espace entre départ et arrivée for (const line of arrLines.slice(0, 3)) { page.drawText(line, { x: cDesig, y: addrY, size: 8, font, color: mid }); addrY -= 11; } // Valeurs numériques (centrées verticalement avec le titre) const numY = articleTitleY - 6; // QTÉ centré const qteVal = '1'; const qteValW = measure(qteVal, 10); page.drawText(qteVal, { x: cQte + 20 - qteValW / 2, y: numY, size: 10, font, color: dark, }); page.drawText(montantStr, { x: cPu, y: numY, size: 10, font, color: dark, }); drawRight(montantStr, R - 8, numY, 10, fontBold, dark); // Ligne de fin d'article const rowBottom = Math.min(addrY, numY - 16) - 6; hLine(rowBottom); // ════════════════════════════════════════════════════════ // TOTAUX // ════════════════════════════════════════════════════════ y = rowBottom - 20; const tLabelX = R - 160; const tValX = R - 8; // Sous-total page.drawText('Sous-total', { x: tLabelX, y, size: 10, font, color: dark, }); drawRight(montantStr, tValX, y, 10, font, dark); // Séparateur y -= 20; hLine(y + 8, tLabelX, R - tLabelX); // Total dû (surligné) y -= 4; page.drawRectangle({ x: tLabelX - 10, y: y - 6, width: R - tLabelX + 18, height: 26, color: rgb(TEAL_BG.r, TEAL_BG.g, TEAL_BG.b), }); page.drawText('Total dû', { x: tLabelX, y: y + 2, size: 13, font: fontBold, color: teal, }); drawRight(montantStr, tValX, y + 2, 13, fontBold, teal); // Échéance y -= 32; page.drawText(`Échéance de paiement : ${dateEmission}`, { x: tLabelX, y, size: 8, font, color: mid, }); // ════════════════════════════════════════════════════════ // COMPLÉMENT (optionnel) // ════════════════════════════════════════════════════════ if (data.complement) { y -= 34; page.drawText('Note :', { x: M, y, size: 9, font: fontBold, color: dark, }); y -= 14; const compLines = wrapText(data.complement, CW - 20, (t) => measure(t, 9)); for (const line of compLines.slice(0, 4)) { page.drawText(line, { x: M, y, size: 9, font, color: dark }); y -= 13; } } // ════════════════════════════════════════════════════════ // PIED DE PAGE // ════════════════════════════════════════════════════════ const footerY = 55; page.drawRectangle({ x: 0, y: 0, width, height: footerY + 2, color: rgb(0.98, 0.98, 0.98), }); hLine(footerY); page.drawText( 'Ce document fait office de participation financière au transport. Établi par Association MAD.', { x: M, y: 35, size: 8, font, color: mid }, ); // Propulsé par LGX + logo const propText = 'Propulsé par LGX'; const lgxLogoPng = await loadLogoAsPng('lgx-logo.svg', 28); if (lgxLogoPng) { try { const lgxImg = await pdfDoc.embedPng(lgxLogoPng); const lgxH = 16; const lgxW = lgxImg.width * (lgxH / lgxImg.height); const blockW = fontBold.widthOfTextAtSize(propText, 9) + lgxW + 8; const lgxX = R - blockW; page.drawText(propText, { x: lgxX, y: 35, size: 9, font: fontBold, color: mid, }); page.drawImage(lgxImg, { x: lgxX + fontBold.widthOfTextAtSize(propText, 9) + 6, y: 29, width: lgxW, height: lgxH, }); } catch { page.drawText(propText, { x: R - fontBold.widthOfTextAtSize(propText, 9), y: 35, size: 9, font: fontBold, color: teal, }); } } else { page.drawText(propText, { x: R - fontBold.widthOfTextAtSize(propText, 9), y: 35, size: 9, font: fontBold, color: teal, }); } // ════════════════════════════════════════════════════════ // SAUVEGARDE // ════════════════════════════════════════════════════════ const pdfBytes = await pdfDoc.save(); const dir = path.dirname(outputPath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(outputPath, pdfBytes); return Buffer.from(pdfBytes); } export function getParticipationStoragePath(participationId: string): string { return path.join(process.cwd(), 'data', 'participations', `${participationId}.pdf`); }