433 lines
15 KiB
TypeScript
433 lines
15 KiB
TypeScript
|
|
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`);
|
||
|
|
}
|