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:
10
package.json
10
package.json
@@ -52,6 +52,8 @@
|
|||||||
"@trpc/server": "^11.6.0",
|
"@trpc/server": "^11.6.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/pdfkit": "^0.17.4",
|
"@types/pdfkit": "^0.17.4",
|
||||||
|
"@types/pngjs": "^6.0.5",
|
||||||
|
"@types/sharp": "^0.32.0",
|
||||||
"axios": "^1.12.0",
|
"axios": "^1.12.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
@@ -67,6 +69,7 @@
|
|||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"framer-motion": "^12.23.22",
|
"framer-motion": "^12.23.22",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
"jimp": "^1.6.0",
|
||||||
"jose": "6.1.0",
|
"jose": "6.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jspdf": "^3.0.3",
|
"jspdf": "^3.0.3",
|
||||||
@@ -79,6 +82,7 @@
|
|||||||
"nodemailer": "^7.0.11",
|
"nodemailer": "^7.0.11",
|
||||||
"openai": "^4.67.0",
|
"openai": "^4.67.0",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
|
"pngjs": "^7.0.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.11.1",
|
||||||
@@ -86,6 +90,7 @@
|
|||||||
"react-hook-form": "^7.64.0",
|
"react-hook-form": "^7.64.0",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"signature_pad": "^5.1.3",
|
"signature_pad": "^5.1.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"streamdown": "^1.4.0",
|
"streamdown": "^1.4.0",
|
||||||
@@ -134,6 +139,9 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"tailwindcss>nanoid": "3.3.7"
|
"tailwindcss>nanoid": "3.3.7"
|
||||||
}
|
},
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"sharp"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
865
pnpm-lock.yaml
generated
865
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import { jsPDF } from 'jspdf';
|
|||||||
import autoTable from 'jspdf-autotable';
|
import autoTable from 'jspdf-autotable';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { PNG } from 'pngjs';
|
||||||
|
|
||||||
interface InscriptionExport {
|
interface InscriptionExport {
|
||||||
nom: string;
|
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)
|
* 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
|
* 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(
|
export function generateFeuillePresenceJour(
|
||||||
feuilleInfo: FeuillePresenceInfo,
|
feuilleInfo: FeuillePresenceInfo,
|
||||||
dateInfo: { id: number; dateDebut: Date; dateFin: Date; ordre: number }
|
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)
|
// Format A4 portrait : largeur utile ~190mm (210 - 10*2)
|
||||||
const doc = new jsPDF({ orientation: 'portrait', format: 'a4' });
|
const doc = new jsPDF({ orientation: 'portrait', format: 'a4' });
|
||||||
|
|
||||||
// Charger le logo
|
// Charger le logo (avec cache)
|
||||||
let logoData: string | null = null;
|
let logoData: string | null = null;
|
||||||
try {
|
try {
|
||||||
const logoPath = path.resolve(process.cwd(), 'client/public/itinova-logo.png');
|
const logoPath = path.resolve(process.cwd(), 'client/public/itinova-logo.png');
|
||||||
const logoBuffer = fs.readFileSync(logoPath);
|
logoData = loadImageForPdf(logoPath);
|
||||||
logoData = `data:image/png;base64,${logoBuffer.toString('base64')}`;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Logo Itinova non trouvé, génération sans logo:', 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 =
|
const publicCibleLabel =
|
||||||
feuilleInfo.publicCible === 'directeur' ? 'Directeurs' :
|
feuilleInfo.publicCible === 'directeur' ? 'Directeurs' :
|
||||||
feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' :
|
feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' :
|
||||||
@@ -191,17 +263,6 @@ export function generateFeuillePresenceJour(
|
|||||||
const fonctionLabel = (f: string | null) =>
|
const fonctionLabel = (f: string | null) =>
|
||||||
f === 'directeur' ? 'Directeur' : f === 'chef_service' ? 'Chef de service' : 'Autre';
|
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', {
|
const dateLabel = dateInfo.dateDebut.toLocaleDateString('fr-FR', {
|
||||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
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
|
* 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
|
feuilleInfo: FeuillePresenceInfo
|
||||||
): Array<{ ordre: number; filename: string; buffer: Buffer }> {
|
): Promise<Array<{ ordre: number; filename: string; buffer: Buffer }>> {
|
||||||
return feuilleInfo.dates.map(dateInfo => {
|
// 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 buffer = generateFeuillePresenceJour(feuilleInfo, dateInfo);
|
||||||
const dateStr = dateInfo.dateDebut.toLocaleDateString('fr-FR').replace(/\//g, '-');
|
// Format : feuille_presence_Groupe A_jour3_27-02-2026.pdf
|
||||||
const filename = `feuille_presence_jour${dateInfo.ordre}_${dateStr}.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 { 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,
|
formationNom: formation.nom,
|
||||||
sequenceNom: sequence.nom,
|
sequenceNom: sequence.nom,
|
||||||
dates: dates.map(d => ({
|
dates: dates.map(d => ({
|
||||||
|
|||||||
8
todo.md
8
todo.md
@@ -1716,3 +1716,11 @@
|
|||||||
- [x] Adapter le frontend pour télécharger les PDFs par jour (décalage 500ms entre chaque)
|
- [x] Adapter le frontend pour télécharger les PDFs par jour (décalage 500ms entre chaque)
|
||||||
- [x] Tester sur TEST
|
- [x] Tester sur TEST
|
||||||
- [x] Déployer sur PROD
|
- [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
|
||||||
|
|||||||
Reference in New Issue
Block a user