/** * Génération PDF Ventilation FreePro côté serveur avec pdf-lib. * Garantit 1 page A4 portrait quelle que soit la taille des données. */ import { PDFDocument, rgb, StandardFonts } from "pdf-lib"; interface PdfLine { structure: string; type: string; montantCentimes: number; } interface PdfImportInfo { mois: string; // ex: "01/2025" refPiece: string; } function formatMontant(centimes: number): string { const euros = centimes / 100; const neg = euros < 0; const abs = Math.abs(euros); const str = abs.toFixed(2).replace(".", ","); const parts = str.split(","); parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, " "); return (neg ? "-" : "") + parts.join(",") + " EUR"; } export async function generateFreeproPdf( info: PdfImportInfo, lines: PdfLine[] ): Promise { const pdfDoc = await PDFDocument.create(); const page = pdfDoc.addPage([595.28, 841.89]); // A4 portrait en points (72dpi) const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica); const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); const { width, height } = page.getSize(); const marginX = 40; const tableStartY = height - 90; // Y depuis le bas (pdf-lib: 0=bas) const tableEndY = 28; const usableW = width - marginX * 2; // Colonnes const col0W = usableW * 0.45; const col1W = usableW * 0.32; const col2W = usableW * 0.23; const col1X = marginX + col0W; const col2X = col1X + col1W; // Calcul hauteur de ligne const nbRows = lines.length + 2; // header + data + footer const availH = tableStartY - tableEndY; const rowH = availH / nbRows; const fontSize = Math.max(5, Math.min(9, rowH * 0.55)); // ── En-tête du document ────────────────────────────────────────────────── const now = new Date(); // Date édition (haut droite) page.drawText( `Edite le ${now.toLocaleDateString("fr-FR")} - ${now.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}`, { x: width - marginX - 120, y: height - 20, size: 7, font: helvetica, color: rgb(0.4, 0.4, 0.4), } ); // Titre const title = "Ventilation facture FREE PRO"; const titleW = helveticaBold.widthOfTextAtSize(title, 14); page.drawText(title, { x: (width - titleW) / 2, y: height - 38, size: 14, font: helveticaBold, color: rgb(0, 0, 0), }); // Mois const moisLabel = info.mois ? (() => { const [mm, yyyy] = info.mois.split("/"); const d = new Date(parseInt(yyyy), parseInt(mm) - 1, 1); return `${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}`; })() : info.mois; const moisW = helveticaBold.widthOfTextAtSize(moisLabel, 11); page.drawText(moisLabel, { x: (width - moisW) / 2, y: height - 56, size: 11, font: helveticaBold, color: rgb(0, 0, 0), }); // Ref pièce page.drawText("ref_piece :", { x: marginX, y: height - 74, size: 8, font: helveticaBold, color: rgb(0, 0, 0), }); page.drawText(info.refPiece || "", { x: marginX + 60, y: height - 74, size: 8, font: helvetica, color: rgb(0, 0, 0), }); // ── Helpers de dessin ──────────────────────────────────────────────────── const drawHLine = (yFromTop: number, lw: number, gray: number) => { const y = height - yFromTop; page.drawLine({ start: { x: marginX, y }, end: { x: marginX + usableW, y }, thickness: lw, color: rgb(gray, gray, gray), }); }; const drawVLines = (yFromTop: number, h: number) => { const y = height - yFromTop; const xs = [marginX, col1X, col2X, marginX + usableW]; for (const x of xs) { page.drawLine({ start: { x, y }, end: { x, y: y - h }, thickness: 0.3, color: rgb(0.7, 0.7, 0.7), }); } }; const drawRowBg = (yFromTop: number, bgGray: number) => { page.drawRectangle({ x: marginX, y: height - yFromTop - rowH, width: usableW, height: rowH, color: rgb(bgGray, bgGray, bgGray), }); }; const drawCell = ( text: string, xLeft: number, xRight: number, yFromTop: number, bold: boolean, align: "left" | "right" = "left" ) => { const font = bold ? helveticaBold : helvetica; const maxW = xRight - xLeft - 4; // Tronquer si trop long let t = text; while (t.length > 1 && font.widthOfTextAtSize(t, fontSize) > maxW) { t = t.slice(0, -1); } const tw = font.widthOfTextAtSize(t, fontSize); const x = align === "right" ? xRight - tw - 2 : xLeft + 2; const y = height - yFromTop - rowH * 0.65; page.drawText(t, { x, y, size: fontSize, font, color: rgb(0, 0, 0) }); }; // ── Header du tableau ──────────────────────────────────────────────────── const headerYFromTop = height - tableStartY; // converti en "depuis le haut" // On travaille directement en coordonnées "depuis le haut" pour la logique // puis on convertit pour pdf-lib // Recalcul en coordonnées "depuis le haut de la page" pour la logique const tStartFromTop = height - tableStartY; // pixels depuis le haut = height - y_pdflib // En fait, simplifions : on calcule tout en "Y depuis le haut de la page" // et on convertit à la fin pour pdf-lib (y_pdflib = height - y_fromtop) // Redéfinir les helpers avec "yT" = Y depuis le haut const hLine = (yT: number, lw: number, gray: number) => { page.drawLine({ start: { x: marginX, y: height - yT }, end: { x: marginX + usableW, y: height - yT }, thickness: lw, color: rgb(gray, gray, gray), }); }; const vLines = (yT: number, h: number) => { for (const x of [marginX, col1X, col2X, marginX + usableW]) { page.drawLine({ start: { x, y: height - yT }, end: { x, y: height - yT - h }, thickness: 0.3, color: rgb(0.7, 0.7, 0.7), }); } }; const rowBg = (yT: number, gray: number) => { page.drawRectangle({ x: marginX, y: height - yT - rowH, width: usableW, height: rowH, color: rgb(gray, gray, gray), }); }; const cell = ( text: string, xL: number, xR: number, yT: number, bold: boolean, align: "left" | "right" = "left" ) => { const font = bold ? helveticaBold : helvetica; const maxW = xR - xL - 4; let t = text; while (t.length > 1 && font.widthOfTextAtSize(t, fontSize) > maxW) { t = t.slice(0, -1); } const tw = font.widthOfTextAtSize(t, fontSize); const x = align === "right" ? xR - tw - 2 : xL + 2; const y = height - yT - rowH * 0.65; page.drawText(t, { x, y, size: fontSize, font, color: rgb(0, 0, 0) }); }; // Tableau commence à yT = 90 (depuis le haut) const tableTopYT = 90; // Header rowBg(tableTopYT, 0.88); hLine(tableTopYT, 0.5, 0); cell("Structure", marginX, col1X, tableTopYT, true); cell("Type", col1X, col2X, tableTopYT, true); cell("Montant TTC", col2X, marginX + usableW, tableTopYT, true, "right"); hLine(tableTopYT + rowH, 0.5, 0); vLines(tableTopYT, rowH); // Lignes de données for (let i = 0; i < lines.length; i++) { const l = lines[i]; const yT = tableTopYT + rowH * (i + 1); if (i % 2 === 1) rowBg(yT, 0.97); cell(l.structure || "", marginX, col1X, yT, false); cell(l.type || "", col1X, col2X, yT, false); cell(formatMontant(l.montantCentimes), col2X, marginX + usableW, yT, false, "right"); hLine(yT + rowH, 0.2, 0.78); vLines(yT, rowH); } // Footer total const totalCentimes = lines.reduce((s, l) => s + l.montantCentimes, 0); const footerYT = tableTopYT + rowH * (lines.length + 1); rowBg(footerYT, 0.88); hLine(footerYT, 0.5, 0); cell("Total general", marginX, col1X, footerYT, true); cell(formatMontant(totalCentimes), col2X, marginX + usableW, footerYT, true, "right"); hLine(footerYT + rowH, 0.5, 0); vLines(footerYT, rowH); return pdfDoc.save(); }