Files
MAD-Platform/lib/participation-pdf.ts

433 lines
15 KiB
TypeScript
Raw Normal View History

2026-02-15 14:36:28 +01:00
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<Buffer | null> {
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<Buffer> {
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`);
}