Files
formation-manager-itinova/server/exportService.ts

622 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Service d'export des données en Excel et PDF
*/
import * as XLSX from 'xlsx';
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
import * as fs from 'fs';
import * as path from 'path';
import { PNG } from 'pngjs';
interface InscriptionExport {
nom: string;
prenom: string;
email: string;
codeEtablissement: string | null;
fonction: string | null;
statut: string;
dateInscription: Date;
}
interface SequenceInfo {
formationNom: string;
sequenceNom: string;
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
lieu: string;
publicCible: string;
inscriptions: InscriptionExport[];
}
interface ApprenantPresence {
nom: string;
prenom: string;
codeEtablissement: string | null;
fonction: string | null;
presences?: Array<{
dateFormationId: number;
periode: 'matin' | 'apres_midi';
signatureUrl: string | null;
}>;
}
interface FeuillePresenceInfo {
formationNom: string;
sequenceNom: string;
dates: Array<{ id: number; dateDebut: Date; dateFin: Date; ordre: number }>;
lieu: string;
publicCible: string;
formateur?: string;
apprenants: ApprenantPresence[];
signaturesFormateurs?: Array<{
dateFormationId: number;
periode: 'matin' | 'apres_midi';
signatureUrl: string | null;
}>;
}
/**
* Génère un fichier Excel avec la liste des inscrits
*/
export function generateExcelExport(sequenceInfo: SequenceInfo): Buffer {
// Préparer les données
const data = sequenceInfo.inscriptions.map(i => ({
'Nom': i.nom,
'Prénom': i.prenom,
'Email': i.email,
'Code établissement': i.codeEtablissement || '',
'Fonction': i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre',
'Statut': i.statut === 'confirmee' ? 'Confirmée' : i.statut === 'liste_attente' ? 'Liste d\'attente' : 'Annulée',
'Date d\'inscription': i.dateInscription.toLocaleDateString('fr-FR'),
}));
// Créer le workbook
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Inscrits');
// Ajouter une feuille d'informations
const infoData = [
{ 'Information': 'Formation', 'Valeur': sequenceInfo.formationNom },
{ 'Information': 'Séquence', 'Valeur': sequenceInfo.sequenceNom },
{ 'Information': 'Lieu', 'Valeur': sequenceInfo.lieu },
{ 'Information': 'Public cible', 'Valeur': sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous' : 'Autre' },
{ 'Information': 'Nombre d\'inscrits', 'Valeur': sequenceInfo.inscriptions.length.toString() },
];
// Ajouter les dates
sequenceInfo.dates.forEach(date => {
infoData.push({
'Information': `Date ${date.ordre}`,
'Valeur': date.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
});
});
const wsInfo = XLSX.utils.json_to_sheet(infoData);
XLSX.utils.book_append_sheet(wb, wsInfo, 'Informations');
// Générer le buffer
return Buffer.from(XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }));
}
/**
* Génère un PDF avec la liste des inscrits
*/
export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer {
const doc = new jsPDF();
// Titre
doc.setFontSize(18);
doc.text('Liste des inscrits', 14, 20);
// Informations de séquence
doc.setFontSize(10);
let yPos = 35;
doc.text(`Formation : ${sequenceInfo.formationNom}`, 14, yPos);
yPos += 6;
doc.text(`Séquence : ${sequenceInfo.sequenceNom}`, 14, yPos);
yPos += 6;
doc.text(`Lieu : ${sequenceInfo.lieu}`, 14, yPos);
yPos += 6;
doc.text(`Public cible : ${sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous' : 'Autre'}`, 14, yPos);
yPos += 8;
// Dates de formation
doc.setFontSize(9);
sequenceInfo.dates.forEach(date => {
doc.text(`Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}`, 14, yPos);
yPos += 5;
});
// Tableau des inscrits
const tableData = sequenceInfo.inscriptions.map(i => [
i.nom,
i.prenom,
i.email,
i.codeEtablissement || '',
i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre',
i.statut === 'confirmee' ? 'Confirmée' : i.statut === 'liste_attente' ? 'Liste d\'attente' : 'Annulée',
]);
autoTable(doc, {
startY: yPos + 10,
head: [['Nom', 'Prénom', 'Email', 'Code étab.', 'Fonction', 'Statut']],
body: tableData,
styles: { fontSize: 9 },
headStyles: { fillColor: [37, 99, 235] },
});
return Buffer.from(doc.output('arraybuffer'));
}
/**
* Génère un PDF de feuille de présence pour UN SEUL jour (sections matin + après-midi + signatures formateur)
* Retourne un Buffer PDF
*/
/**
* Cache des images compressées pour le PDF.
* Clé = chemin absolu du fichier, valeur = data URI JPEG compressé ou null.
*/
const _imageCache = new Map<string, string | null>();
/**
* Charge et compresse une image (PNG/JPEG) en JPEG 50% max 300x100px.
* Utilise un cache pour éviter de retraiter plusieurs fois le même fichier.
* SYNCHRONE : utilise le cache pré-rempli par preloadSignatures().
*/
function loadImageForPdf(filePath: string): string | null {
return _imageCache.get(filePath) ?? null;
}
/**
* Pré-charge et compresse toutes les signatures en JPEG avant génération PDF.
* À appeler en async avant generateFeuillePresenceJour().
*/
async function preloadSignatures(paths: string[]): Promise<void> {
await Promise.all(paths.map(async (filePath) => {
if (_imageCache.has(filePath)) return;
try {
if (!fs.existsSync(filePath)) { _imageCache.set(filePath, null); return; }
const raw = fs.readFileSync(filePath);
const isJpeg = raw[0] === 0xFF && raw[1] === 0xD8;
if (isJpeg) {
// JPEG : pas de redimensionnement, juste encoder en base64
_imageCache.set(filePath, `data:image/jpeg;base64,${raw.toString('base64')}`);
return;
}
// PNG : lire, redimensionner à max 500x160, ré-encoder
try {
const src = PNG.sync.read(raw);
const maxW = 500, maxH = 160;
const ratio = Math.min(maxW / src.width, maxH / src.height, 1); // ne pas agrandir
if (ratio >= 0.95) {
// Déjà petite, encoder directement
_imageCache.set(filePath, `data:image/png;base64,${raw.toString('base64')}`);
return;
}
const newW = Math.max(1, Math.round(src.width * ratio));
const newH = Math.max(1, Math.round(src.height * ratio));
const dst = new PNG({ width: newW, height: newH });
for (let y = 0; y < newH; y++) {
for (let x = 0; x < newW; x++) {
const srcX = Math.min(src.width - 1, Math.floor(x / ratio));
const srcY = Math.min(src.height - 1, Math.floor(y / ratio));
const si = (srcY * src.width + srcX) * 4;
const di = (y * newW + x) * 4;
dst.data[di] = src.data[si];
dst.data[di+1] = src.data[si+1];
dst.data[di+2] = src.data[si+2];
dst.data[di+3] = src.data[si+3];
}
}
const out = PNG.sync.write(dst, { colorType: 2, filterType: 4 });
_imageCache.set(filePath, `data:image/png;base64,${out.toString('base64')}`);
} catch {
// Fallback : encoder le PNG brut
_imageCache.set(filePath, `data:image/png;base64,${raw.toString('base64')}`);
}
} catch { _imageCache.set(filePath, null); }
}));
}
export function generateFeuillePresenceJour(
feuilleInfo: FeuillePresenceInfo,
dateInfo: { id: number; dateDebut: Date; dateFin: Date; ordre: number }
): Buffer {
// Format A4 portrait : largeur utile ~190mm (210 - 10*2)
const doc = new jsPDF({ orientation: 'portrait', format: 'a4' });
// Charger le logo (avec cache)
let logoData: string | null = null;
try {
const logoPath = path.resolve(process.cwd(), 'client/public/itinova-logo.png');
logoData = loadImageForPdf(logoPath);
} catch (error) {
console.warn('Logo Itinova non trouvé, génération sans logo:', error);
}
const loadSignatureBase64 = (signatureUrl: string | null): string | null => {
if (!signatureUrl) return null;
const fp = path.resolve(process.cwd(), signatureUrl.startsWith('/') ? signatureUrl.substring(1) : signatureUrl);
return loadImageForPdf(fp);
};
const publicCibleLabel =
feuilleInfo.publicCible === 'directeur' ? 'Directeurs' :
feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' :
feuilleInfo.publicCible === 'tous' ? 'Tous' : 'Autre';
const fonctionLabel = (f: string | null) =>
f === 'directeur' ? 'Directeur' : f === 'chef_service' ? 'Chef de service' : 'Autre';
const dateLabel = dateInfo.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
});
// ── En-tête ──────────────────────────────────────────────────────────────
if (logoData) doc.addImage(logoData, 'PNG', 160, 8, 40, 15);
doc.setFontSize(16);
doc.setFont('helvetica', 'bold');
doc.text('Feuille de présence', 10, 18);
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
let yPos = 28;
doc.text(`Formation : ${feuilleInfo.formationNom}`, 10, yPos); yPos += 5;
doc.text(`Séquence : ${feuilleInfo.sequenceNom}`, 10, yPos); yPos += 5;
doc.text(`Lieu : ${feuilleInfo.lieu}`, 10, yPos); yPos += 5;
if (feuilleInfo.formateur) { doc.text(`Formateur : ${feuilleInfo.formateur}`, 10, yPos); yPos += 5; }
doc.text(`Public cible : ${publicCibleLabel}`, 10, yPos); yPos += 5;
doc.setFontSize(11);
doc.setFont('helvetica', 'bold');
doc.text(`Jour ${dateInfo.ordre} ${dateLabel}`, 10, yPos + 3);
doc.setFont('helvetica', 'normal');
yPos += 10;
// ── Tableau unique avec colonnes Signature Matin + Signature Après-midi ──
const tableData = feuilleInfo.apprenants.map(apprenant => {
const presenceMatin = apprenant.presences?.find(
p => p.dateFormationId === dateInfo.id && p.periode === 'matin'
);
const presenceApresMidi = apprenant.presences?.find(
p => p.dateFormationId === dateInfo.id && p.periode === 'apres_midi'
);
return [
apprenant.nom,
apprenant.prenom,
apprenant.codeEtablissement || '',
fonctionLabel(apprenant.fonction),
presenceMatin?.signatureUrl || '', // col 4 : sig matin
presenceApresMidi?.signatureUrl || '', // col 5 : sig après-midi
];
});
autoTable(doc, {
startY: yPos,
head: [['Nom', 'Prénom', 'Code étab.', 'Fonction', 'Signature\nMatin', 'Signature\nAprès-midi']],
body: tableData,
margin: { left: 10, right: 10 },
styles: { fontSize: 8, cellPadding: 2, minCellHeight: 18, overflow: 'linebreak' },
headStyles: { fillColor: [37, 99, 235], halign: 'center', fontSize: 8 },
columnStyles: {
0: { cellWidth: 28 }, // Nom
1: { cellWidth: 28 }, // Prénom
2: { cellWidth: 20 }, // Code étab.
3: { cellWidth: 24 }, // Fonction
4: { cellWidth: 45 }, // Signature Matin
5: { cellWidth: 45 }, // Signature Après-midi
},
didDrawCell: (data) => {
if (data.section === 'body' && (data.column.index === 4 || data.column.index === 5)) {
const sig = loadSignatureBase64(data.cell.raw as string);
if (sig) {
doc.addImage(sig, 'PNG', data.cell.x + 2, data.cell.y + 2, data.cell.width - 4, data.cell.height - 4);
}
}
},
});
// ── Signatures formateur (Matin et Après-midi sur la même ligne) ──────────
const finalY = (doc as any).lastAutoTable.finalY || yPos + 50;
const sigY = finalY + 10;
doc.setFontSize(9);
doc.setFont('helvetica', 'bold');
doc.text('Signatures du formateur :', 10, sigY);
doc.setFont('helvetica', 'normal');
// Matin (gauche)
doc.text('Matin :', 10, sigY + 8);
const sigMatinFormateur = feuilleInfo.signaturesFormateurs?.find(
s => s.dateFormationId === dateInfo.id && s.periode === 'matin'
);
const sigMatinBase64 = loadSignatureBase64(sigMatinFormateur?.signatureUrl ?? null);
if (sigMatinBase64) {
doc.addImage(sigMatinBase64, 'PNG', 30, sigY + 2, 60, 14);
} else {
doc.line(30, sigY + 8, 90, sigY + 8);
}
// Après-midi (droite)
doc.text('Après-midi :', 110, sigY + 8);
const sigApresMidiFormateur = feuilleInfo.signaturesFormateurs?.find(
s => s.dateFormationId === dateInfo.id && s.periode === 'apres_midi'
);
const sigApresMidiBase64 = loadSignatureBase64(sigApresMidiFormateur?.signatureUrl ?? null);
if (sigApresMidiBase64) {
doc.addImage(sigApresMidiBase64, 'PNG', 140, sigY + 2, 60, 14);
} else {
doc.line(140, sigY + 8, 200, sigY + 8);
}
return Buffer.from(doc.output('arraybuffer'));
}
/**
* Génère un tableau de { ordre, filename, buffer } un PDF par jour
* Pré-charge et compresse toutes les signatures avant génération.
*/
export async function generateFeuillePresenceParJour(
feuilleInfo: FeuillePresenceInfo
): Promise<Array<{ ordre: number; filename: string; buffer: Buffer }>> {
// Collecter tous les chemins d'images à pré-charger
const imagePaths: string[] = [];
// Logo
const logoPath = path.resolve(process.cwd(), 'client/public/itinova-logo.png');
if (fs.existsSync(logoPath)) imagePaths.push(logoPath);
// Signatures apprenants
feuilleInfo.apprenants.forEach(a => {
a.presences?.forEach(p => {
if (p.signatureUrl) {
const fp = path.resolve(process.cwd(), p.signatureUrl.startsWith('/') ? p.signatureUrl.substring(1) : p.signatureUrl);
if (fs.existsSync(fp)) imagePaths.push(fp);
}
});
});
// Signatures formateurs
feuilleInfo.signaturesFormateurs?.forEach(s => {
if (s.signatureUrl) {
const fp = path.resolve(process.cwd(), s.signatureUrl.startsWith('/') ? s.signatureUrl.substring(1) : s.signatureUrl);
if (fs.existsSync(fp)) imagePaths.push(fp);
}
});
// Pré-charger et compresser toutes les images en parallèle
await preloadSignatures([...new Set(imagePaths)]);
const results = feuilleInfo.dates.map(dateInfo => {
const buffer = generateFeuillePresenceJour(feuilleInfo, dateInfo);
// Format : feuille_presence_Groupe A_jour3_27-02-2026.pdf
const dateStr = dateInfo.dateDebut.toLocaleDateString('fr-FR', {
day: '2-digit', month: '2-digit', year: 'numeric'
}).replace(/\//g, '-'); // ex: 27-02-2026
const sequenceNomSafe = feuilleInfo.sequenceNom.replace(/[/\\:*?"<>|]/g, '_');
const filename = `feuille_presence_${sequenceNomSafe}_jour${dateInfo.ordre}_${dateStr}.pdf`;
return { ordre: dateInfo.ordre, filename, buffer };
});
return results;
}
/**
* Génère une feuille de présence PDF avec signatures matin/après-midi pour chaque journée
* @deprecated Utiliser generateFeuillePresenceParJour à la place
*/
export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffer {
const doc = new jsPDF();
// Charger le logo
let logoData: string | null = null;
try {
// Utiliser un chemin absolu depuis la racine du projet
const logoPath = path.resolve(process.cwd(), 'client/public/itinova-logo.png');
const logoBuffer = fs.readFileSync(logoPath);
logoData = `data:image/png;base64,${logoBuffer.toString('base64')}`;
} catch (error) {
console.warn('Logo Itinova non trouvé, génération sans logo:', error);
}
// Fonction pour générer une page de feuille de présence pour une journée
const generatePageForDate = (dateInfo: { dateDebut: Date; dateFin: Date; ordre: number }, isFirstPage: boolean) => {
if (!isFirstPage) {
doc.addPage();
}
// Ajouter le logo en haut à droite si disponible
if (logoData) {
doc.addImage(logoData, 'PNG', 160, 10, 40, 15);
}
// Titre
doc.setFontSize(18);
doc.text('Feuille de présence', 14, 20);
// Informations de séquence
doc.setFontSize(10);
let yPos = 35;
doc.text(`Formation : ${feuilleInfo.formationNom}`, 14, yPos);
yPos += 6;
doc.text(`Séquence : ${feuilleInfo.sequenceNom}`, 14, yPos);
yPos += 6;
doc.text(`Lieu : ${feuilleInfo.lieu}`, 14, yPos);
yPos += 6;
// Ajouter le formateur si disponible
if (feuilleInfo.formateur) {
doc.text(`Formateur : ${feuilleInfo.formateur}`, 14, yPos);
yPos += 6;
}
doc.text(`Public cible : ${feuilleInfo.publicCible === 'directeur' ? 'Directeurs' : feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' : feuilleInfo.publicCible === 'tous' ? 'Tous' : 'Autre'}`, 14, yPos);
yPos += 8;
// Date de la journée
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.text(`Date ${dateInfo.ordre} : ${dateInfo.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}`, 14, yPos);
doc.setFont('helvetica', 'normal');
yPos += 10;
// Tableau de présence avec signatures matin et après-midi
const tableData = feuilleInfo.apprenants.map(i => {
// Trouver les présences pour cette date (utiliser l'ID de la date, pas l'ordre)
const presenceMatin = i.presences?.find(p => p.dateFormationId === dateInfo.id && p.periode === 'matin');
const presenceApresMidi = i.presences?.find(p => p.dateFormationId === dateInfo.id && p.periode === 'apres_midi');
return [
i.nom,
i.prenom,
i.codeEtablissement || '',
i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre',
presenceMatin?.signatureUrl || '', // Signature matin (sera remplacée par l'image)
presenceApresMidi?.signatureUrl || '', // Signature après-midi (sera remplacée par l'image)
];
});
autoTable(doc, {
startY: yPos,
head: [['Nom', 'Prénom', 'Code étab.', 'Fonction', 'Signature\nMatin', 'Signature\nAprès-midi']],
body: tableData,
margin: { left: 10, right: 10 }, // Marges réduites pour maximiser la largeur
styles: {
fontSize: 8,
cellPadding: 2,
minCellHeight: 15, // Augmenté pour les signatures
overflow: 'linebreak',
},
headStyles: {
fillColor: [37, 99, 235],
halign: 'center',
fontSize: 8,
},
columnStyles: {
0: { cellWidth: 28 }, // Nom
1: { cellWidth: 28 }, // Prénom
2: { cellWidth: 20 }, // Code établissement
3: { cellWidth: 24 }, // Fonction
4: { cellWidth: 40 }, // Signature matin
5: { cellWidth: 40 }, // Signature après-midi
},
didDrawCell: (data) => {
// Insérer les images de signatures dans les colonnes 4 et 5
if (data.section === 'body' && (data.column.index === 4 || data.column.index === 5)) {
const signatureUrl = data.cell.raw as string;
if (signatureUrl && signatureUrl.startsWith('/uploads/signatures/')) {
try {
// Construire le chemin absolu vers le fichier de signature
const signaturePath = path.resolve(process.cwd(), signatureUrl.substring(1)); // Enlever le / du début
// Vérifier si le fichier existe
if (fs.existsSync(signaturePath)) {
// Charger l'image
const signatureBuffer = fs.readFileSync(signaturePath);
const signatureBase64 = `data:image/png;base64,${signatureBuffer.toString('base64')}`;
// Insérer l'image dans la cellule
const cellX = data.cell.x + 2;
const cellY = data.cell.y + 2;
const cellWidth = data.cell.width - 4;
const cellHeight = data.cell.height - 4;
doc.addImage(signatureBase64, 'PNG', cellX, cellY, cellWidth, cellHeight);
}
} catch (error) {
console.error('Erreur lors du chargement de la signature:', error);
}
}
}
},
});
// Ajouter deux champs de signature pour le formateur (Matin et Après-midi)
const finalY = (doc as any).lastAutoTable.finalY || yPos + 50;
const signatureY = finalY + 20;
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text('Signatures du formateur :', 14, signatureY);
doc.setFont('helvetica', 'normal');
// Récupérer les signatures formateurs pour cette date
const signatureMatin = feuilleInfo.signaturesFormateurs?.find(
s => s.dateFormationId === dateInfo.id && s.periode === 'matin'
);
const signatureApresMidi = feuilleInfo.signaturesFormateurs?.find(
s => s.dateFormationId === dateInfo.id && s.periode === 'apres_midi'
);
// Signature Matin
doc.setFontSize(9);
doc.text('Matin :', 14, signatureY + 8);
if (signatureMatin?.signatureUrl) {
try {
const signaturePath = path.resolve(process.cwd(), signatureMatin.signatureUrl.substring(1));
if (fs.existsSync(signaturePath)) {
const signatureBuffer = fs.readFileSync(signaturePath);
const signatureBase64 = `data:image/png;base64,${signatureBuffer.toString('base64')}`;
doc.addImage(signatureBase64, 'PNG', 30, signatureY + 2, 60, 15);
} else {
doc.line(30, signatureY + 8, 90, signatureY + 8);
}
} catch (error) {
console.error('Erreur lors du chargement de la signature formateur matin:', error);
doc.line(30, signatureY + 8, 90, signatureY + 8);
}
} else {
doc.line(30, signatureY + 8, 90, signatureY + 8);
}
// Signature Après-midi
doc.text('Après-midi :', 110, signatureY + 8);
if (signatureApresMidi?.signatureUrl) {
try {
const signaturePath = path.resolve(process.cwd(), signatureApresMidi.signatureUrl.substring(1));
if (fs.existsSync(signaturePath)) {
const signatureBuffer = fs.readFileSync(signaturePath);
const signatureBase64 = `data:image/png;base64,${signatureBuffer.toString('base64')}`;
doc.addImage(signatureBase64, 'PNG', 140, signatureY + 2, 60, 15);
} else {
doc.line(140, signatureY + 8, 200, signatureY + 8);
}
} catch (error) {
console.error('Erreur lors du chargement de la signature formateur après-midi:', error);
doc.line(140, signatureY + 8, 200, signatureY + 8);
}
} else {
doc.line(140, signatureY + 8, 200, signatureY + 8);
}
};
// Générer une page pour chaque date
feuilleInfo.dates.forEach((date, index) => {
generatePageForDate(date, index === 0);
});
return Buffer.from(doc.output('arraybuffer'));
}