Checkpoint: V1.27 - Optimisation taille PDFs feuille de présence : compression pngjs (résolution 500x160px), réduction de ~40 Mo à < 1 Mo par fichier
This commit is contained in:
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -166,6 +167,72 @@ export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer {
|
||||
* 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 }
|
||||
@@ -173,16 +240,21 @@ export function generateFeuillePresenceJour(
|
||||
// Format A4 portrait : largeur utile ~190mm (210 - 10*2)
|
||||
const doc = new jsPDF({ orientation: 'portrait', format: 'a4' });
|
||||
|
||||
// Charger le logo
|
||||
// Charger le logo (avec cache)
|
||||
let logoData: string | null = null;
|
||||
try {
|
||||
const logoPath = path.resolve(process.cwd(), 'client/public/itinova-logo.png');
|
||||
const logoBuffer = fs.readFileSync(logoPath);
|
||||
logoData = `data:image/png;base64,${logoBuffer.toString('base64')}`;
|
||||
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' :
|
||||
@@ -191,17 +263,6 @@ export function generateFeuillePresenceJour(
|
||||
const fonctionLabel = (f: string | null) =>
|
||||
f === 'directeur' ? 'Directeur' : f === 'chef_service' ? 'Chef de service' : 'Autre';
|
||||
|
||||
const loadSignatureBase64 = (signatureUrl: string | null): string | null => {
|
||||
if (!signatureUrl) return null;
|
||||
try {
|
||||
const p = path.resolve(process.cwd(), signatureUrl.startsWith('/') ? signatureUrl.substring(1) : signatureUrl);
|
||||
if (fs.existsSync(p)) {
|
||||
return `data:image/png;base64,${fs.readFileSync(p).toString('base64')}`;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
};
|
||||
|
||||
const dateLabel = dateInfo.dateDebut.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||
});
|
||||
@@ -309,16 +370,46 @@ export function generateFeuillePresenceJour(
|
||||
|
||||
/**
|
||||
* 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 function generateFeuillePresenceParJour(
|
||||
export async function generateFeuillePresenceParJour(
|
||||
feuilleInfo: FeuillePresenceInfo
|
||||
): Array<{ ordre: number; filename: string; buffer: Buffer }> {
|
||||
return feuilleInfo.dates.map(dateInfo => {
|
||||
): 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);
|
||||
const dateStr = dateInfo.dateDebut.toLocaleDateString('fr-FR').replace(/\//g, '-');
|
||||
const filename = `feuille_presence_jour${dateInfo.ordre}_${dateStr}.pdf`;
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1232,7 +1232,7 @@ export const appRouter = router({
|
||||
};
|
||||
});
|
||||
|
||||
const pdfsParJour = generateFeuillePresenceParJour({
|
||||
const pdfsParJour = await generateFeuillePresenceParJour({
|
||||
formationNom: formation.nom,
|
||||
sequenceNom: sequence.nom,
|
||||
dates: dates.map(d => ({
|
||||
|
||||
Reference in New Issue
Block a user