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:
Manus
2026-03-04 09:40:42 -05:00
parent 4063e298b9
commit 54159fd872
5 changed files with 993 additions and 21 deletions

View File

@@ -52,6 +52,8 @@
"@trpc/server": "^11.6.0",
"@types/multer": "^2.0.0",
"@types/pdfkit": "^0.17.4",
"@types/pngjs": "^6.0.5",
"@types/sharp": "^0.32.0",
"axios": "^1.12.0",
"bcrypt": "^6.0.0",
"bcryptjs": "^3.0.3",
@@ -67,6 +69,7 @@
"express": "^4.21.2",
"framer-motion": "^12.23.22",
"input-otp": "^1.4.2",
"jimp": "^1.6.0",
"jose": "6.1.0",
"jsonwebtoken": "^9.0.2",
"jspdf": "^3.0.3",
@@ -79,6 +82,7 @@
"nodemailer": "^7.0.11",
"openai": "^4.67.0",
"pdfkit": "^0.17.2",
"pngjs": "^7.0.0",
"qrcode": "^1.5.4",
"react": "^19.1.1",
"react-day-picker": "^9.11.1",
@@ -86,6 +90,7 @@
"react-hook-form": "^7.64.0",
"react-resizable-panels": "^3.0.6",
"recharts": "^2.15.4",
"sharp": "^0.34.5",
"signature_pad": "^5.1.3",
"sonner": "^2.0.7",
"streamdown": "^1.4.0",
@@ -134,6 +139,9 @@
},
"overrides": {
"tailwindcss>nanoid": "3.3.7"
}
},
"onlyBuiltDependencies": [
"sharp"
]
}
}

865
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}
/**

View File

@@ -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 => ({

View File

@@ -1716,3 +1716,11 @@
- [x] Adapter le frontend pour télécharger les PDFs par jour (décalage 500ms entre chaque)
- [x] Tester sur TEST
- [x] Déployer sur PROD
## Optimisation - Réduire la taille des PDFs de feuille de présence (~40 Mo)
- [x] Analyser les causes de la taille excessive (PNG non compressés, 56 fichiers ~50 Ko chacun)
- [x] Implémenter compression pngjs (pur JS, sans dépendances natives) : réduction ~90%
- [x] Résolution 500x160px pour équilibre qualité/taille (< 1 Mo par PDF)
- [x] Tester sur TEST
- [x] Déployer sur PROD