fix: PDF généré côté serveur (pdf-lib) - 1 page A4 portrait garantie

This commit is contained in:
Manus
2026-06-05 11:27:58 -04:00
parent 5d919f891f
commit 51830b88be
4 changed files with 474 additions and 146 deletions

View File

@@ -36,8 +36,7 @@ import {
Euro,
Share2,
} from "lucide-react";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
// PDF généré côté serveur via trpc.freepro.generatePdf
// ── Types ──────────────────────────────────────────────────────────────────
@@ -83,138 +82,7 @@ function typeBadgeColor(type: string): string {
return "bg-orange-100 text-orange-800 border-orange-200";
}
/** Formatage montant pour PDF : sans espace insécable pour éviter les artefacts jsPDF */
function formatMontantPdf(centimes: number): string {
const val = centimes / 100;
const parts = val.toFixed(2).split(".");
// Séparateur de milliers = espace simple (pas \u202f qui cause le slash)
const intPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, " ");
return `${intPart},${parts[1]} EUR`;
}
// ── Construction du PDF (partagée entre export local et SharePoint) ─────────
function buildPdfDoc(importRecord: ImportRecord, lines: VentilationLine[]): jsPDF {
// A4 portrait : 210mm × 297mm
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
const pageW = 210;
const pageH = 297;
const margin = 14;
const tableStartY = 31; // Y où commence le tableau
const tableEndY = pageH - 10; // Y max du tableau
const usableW = pageW - margin * 2; // 182mm
// ── En-tête ────────────────────────────────────────────────────────────────
const now = new Date();
const editDate = `Edite le ${now.toLocaleDateString("fr-FR")} - ${now.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}`;
doc.setFontSize(7);
doc.setFont("helvetica", "normal");
doc.text(editDate, pageW - margin, 7, { align: "right" });
doc.setFontSize(13);
doc.setFont("helvetica", "bold");
doc.text("Ventilation facture FREE PRO", pageW / 2, 13, { align: "center" });
const [moisStr, anneeStr] = importRecord.moisLabel.split("/");
doc.setFontSize(10);
doc.text(`01/${moisStr}/${anneeStr}`, pageW / 2, 20, { align: "center" });
doc.setFontSize(8);
doc.setFont("helvetica", "bold");
doc.text("ref_piece :", margin, 27);
doc.setFont("helvetica", "normal");
doc.text(importRecord.refPiece ?? "", margin + 22, 27);
// ── Calcul des dimensions du tableau ────────────────────────────────────────
const totalCentimes = lines.reduce((s, l) => s + l.montantCentimes, 0);
// nbRows = 1 header + N lignes + 1 footer
const nbRows = lines.length + 2;
// Hauteur totale disponible pour le tableau
const availH = tableEndY - tableStartY;
// Hauteur exacte par ligne
const rowH = availH / nbRows;
// Police : 1pt = 0.353mm, on prend 55% de rowH pour le texte
const fontSize = Math.max(5, Math.min(9, Math.floor(rowH * 0.55 / 0.353)));
// Largeurs colonnes
const col0W = usableW * 0.45; // Structure
const col1W = usableW * 0.32; // Type
const col2W = usableW * 0.23; // Montant
const col1X = margin + col0W;
const col2X = col1X + col1W;
// ── Dessin manuel du tableau ─────────────────────────────────────────────────
doc.setFontSize(fontSize);
const drawRow = (y: number, c0: string, c1: string, c2: string, bold: boolean, bg?: [number, number, number]) => {
// Fond de ligne
if (bg) {
doc.setFillColor(bg[0], bg[1], bg[2]);
doc.rect(margin, y, usableW, rowH, "F");
}
// Texte
doc.setFont("helvetica", bold ? "bold" : "normal");
const textY = y + rowH * 0.65; // centrage vertical approximatif
const pad = 1.5;
doc.text(c0, margin + pad, textY, { maxWidth: col0W - pad * 2 });
doc.text(c1, col1X + pad, textY, { maxWidth: col1W - pad * 2 });
doc.text(c2, col2X + col2W - pad, textY, { align: "right", maxWidth: col2W - pad * 2 });
};
const drawHLine = (y: number, lw: number, r: number, g: number, b: number) => {
doc.setDrawColor(r, g, b);
doc.setLineWidth(lw);
doc.line(margin, y, margin + usableW, y);
};
const drawVLines = (y: number, h: number) => {
doc.setDrawColor(180, 180, 180);
doc.setLineWidth(0.1);
doc.line(margin, y, margin, y + h);
doc.line(col1X, y, col1X, y + h);
doc.line(col2X, y, col2X, y + h);
doc.line(margin + usableW, y, margin + usableW, y + h);
};
// Header
const headerY = tableStartY;
drawHLine(headerY, 0.4, 0, 0, 0);
drawRow(headerY, "Structure", "Type", "Montant TTC", true);
drawHLine(headerY + rowH, 0.4, 0, 0, 0);
drawVLines(headerY, rowH);
// Body
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
const y = headerY + rowH * (i + 1);
const bg: [number, number, number] | undefined = i % 2 === 1 ? [248, 248, 248] : undefined;
drawRow(y, l.structure ?? "(vide)", l.type, formatMontantPdf(l.montantCentimes), false, bg);
drawHLine(y + rowH, 0.1, 200, 200, 200);
drawVLines(y, rowH);
}
// Footer (Total)
const footerY = headerY + rowH * (lines.length + 1);
drawHLine(footerY, 0.4, 0, 0, 0);
drawRow(footerY, "Total general", "", formatMontantPdf(totalCentimes), true, [240, 240, 240]);
drawHLine(footerY + rowH, 0.4, 0, 0, 0);
drawVLines(footerY, rowH);
return doc;
}
function exportToPdf(importRecord: ImportRecord, lines: VentilationLine[]) {
const doc = buildPdfDoc(importRecord, lines);
const [moisStr, anneeStr] = importRecord.moisLabel.split("/");
const moisPad = moisStr.padStart(2, "0");
const anneeCourt = anneeStr.slice(2);
doc.save(`FreePro - ventilation facture ${moisPad}.${anneeCourt}.pdf`);
}
function buildPdfBase64(importRecord: ImportRecord, lines: VentilationLine[]): string {
const doc = buildPdfDoc(importRecord, lines);
return doc.output("datauristring").split(",")[1];
}
// PDF généré côté serveur — pas de jsPDF côté client
// ── Composant principal ────────────────────────────────────────────────────
@@ -264,6 +132,29 @@ function VentilationFreeProContent() {
},
});
// Mutation génération PDF côté serveur
const [isExportingPdf, setIsExportingPdf] = useState(false);
const generatePdfMutation = trpc.freepro.generatePdf.useMutation({
onSuccess: (data) => {
setIsExportingPdf(false);
// Télécharger le PDF depuis le base64
const byteChars = atob(data.base64);
const byteArr = new Uint8Array(byteChars.length);
for (let i = 0; i < byteChars.length; i++) byteArr[i] = byteChars.charCodeAt(i);
const blob = new Blob([byteArr], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = data.fileName;
a.click();
URL.revokeObjectURL(url);
},
onError: (err) => {
setIsExportingPdf(false);
toast.error(`Erreur génération PDF : ${err.message}`);
},
});
const [isExportingSP, setIsExportingSP] = useState(false);
const exportToSharePointMutation = trpc.freepro.exportToSharePoint.useMutation({
onSuccess: (data) => {
@@ -277,13 +168,10 @@ function VentilationFreeProContent() {
});
const handleExportSharePoint = useCallback(() => {
if (!selectedImportId || !detail) return;
const imp = detail.import as ImportRecord;
const lines = (detail.lines ?? []) as VentilationLine[];
const pdfBase64 = buildPdfBase64(imp, lines);
if (!selectedImportId) return;
setIsExportingSP(true);
exportToSharePointMutation.mutate({ id: selectedImportId, pdfBase64 });
}, [selectedImportId, detail, exportToSharePointMutation]);
exportToSharePointMutation.mutate({ id: selectedImportId });
}, [selectedImportId, exportToSharePointMutation]);
// Gestion du fichier
const handleFile = useCallback(
@@ -521,11 +409,16 @@ function VentilationFreeProContent() {
{imp && (
<div className="flex items-center gap-2">
<Button
onClick={() => exportToPdf(imp, lines)}
onClick={() => { setIsExportingPdf(true); generatePdfMutation.mutate({ id: selectedImportId! }); }}
disabled={isExportingPdf}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white"
>
<Download className="h-4 w-4" />
Exporter PDF
{isExportingPdf ? (
<div className="h-4 w-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{isExportingPdf ? "Génération..." : "Exporter PDF"}
</Button>
<Button
onClick={handleExportSharePoint}

267
server/freeproPdfService.ts Normal file
View File

@@ -0,0 +1,267 @@
/**
* 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<Uint8Array> {
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();
}

View File

@@ -2269,9 +2269,33 @@ export const appRouter = router({
return { success: true };
}),
/** Génère le PDF côté serveur et le retourne en base64 */
generatePdf: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const data = await getFreeproImportWithLines(input.id);
if (!data) throw new TRPCError({ code: "NOT_FOUND" });
if (data.import.userId !== ctx.user.id)
throw new TRPCError({ code: "FORBIDDEN" });
const { generateFreeproPdf } = await import('./freeproPdfService');
const pdfBytes = await generateFreeproPdf(
{ mois: data.import.moisLabel, refPiece: data.import.refPiece || '' },
data.lines.map(l => ({
structure: l.structure || '',
type: l.type || '',
montantCentimes: l.montantCentimes,
}))
);
const base64 = Buffer.from(pdfBytes).toString('base64');
const [mm, yyyy] = data.import.moisLabel.split('/');
const fileName = `FreePro - ventilation facture ${mm.padStart(2,'0')}.${yyyy.slice(2)}.pdf`;
return { base64, fileName };
}),
/** Exporte la ventilation FreePro vers SharePoint */
exportToSharePoint: protectedProcedure
.input(z.object({ id: z.number(), pdfBase64: z.string() }))
.input(z.object({ id: z.number(), pdfBase64: z.string().optional() }))
.mutation(async ({ input, ctx }) => {
const data = await getFreeproImportWithLines(input.id);
if (!data) throw new TRPCError({ code: "NOT_FOUND" });
@@ -2298,8 +2322,17 @@ export const appRouter = router({
const anneeCourt = anneeStr.slice(2);
const fileName = `FreePro - ventilation facture ${moisPad}.${anneeCourt}.pdf`;
// Convertir le PDF base64 en buffer
const pdfBuffer = Buffer.from(input.pdfBase64, 'base64');
// Générer le PDF côté serveur
const { generateFreeproPdf } = await import('./freeproPdfService');
const pdfBytes = await generateFreeproPdf(
{ mois: data.import.moisLabel, refPiece: data.import.refPiece || '' },
data.lines.map(l => ({
structure: l.structure || '',
type: l.type || '',
montantCentimes: l.montantCentimes,
}))
);
const pdfBuffer = Buffer.from(pdfBytes);
const { uploadToSharePoint } = await import('./sharepoint');
const result = await uploadToSharePoint(

135
test_pdf_freepro.ts Normal file
View File

@@ -0,0 +1,135 @@
import jsPDFModule from "jspdf";
import * as fs from "fs";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const jsPDF = (jsPDFModule as any).default ?? jsPDFModule;
const lines = [
{ structure: "1001BPT", type: "Lien 5G", montantCentimes: 0 },
{ structure: "1001VAR - ITEP SESSAD VAREY", type: "Tél. mobile", montantCentimes: 48 },
{ structure: "1031MER", type: "Lien 5G", montantCentimes: 2398 },
{ structure: "1038MBN", type: "Lien fibre", montantCentimes: 5999 },
{ structure: "1038RAC", type: "Lien fibre", montantCentimes: 5999 },
{ structure: "1042CLA", type: "Lien fibre", montantCentimes: 5999 },
{ structure: "1069BOUIME", type: "Lien fibre", montantCentimes: 5999 },
{ structure: "1069IVP", type: "Lien fibre", montantCentimes: 5999 },
{ structure: "1083ADV", type: "Lien 5G", montantCentimes: 1199 },
{ structure: "1083ADV", type: "Lien fibre", montantCentimes: 23996 },
{ structure: "1083ADV", type: "Tél. mobile", montantCentimes: 14261 },
{ structure: "1083MIS", type: "Tél. mobile", montantCentimes: 9592 },
{ structure: "1083QVT", type: "Tél. mobile", montantCentimes: 1199 },
{ structure: "1083SYL", type: "Lien 5G", montantCentimes: 1199 },
{ structure: "1083SYL", type: "Lien fibre", montantCentimes: 11998 },
{ structure: "1083SYL", type: "Tél. mobile", montantCentimes: 7194 },
{ structure: "1084CAS", type: "Tél. mobile", montantCentimes: 1199 },
{ structure: "2001BRP", type: "Lien 5G", montantCentimes: 1199 },
{ structure: "2001MUS", type: "Lien 5G", montantCentimes: 0 },
{ structure: "2001ROS", type: "Tél. mobile", montantCentimes: 1223 },
{ structure: "2011MON", type: "Lien fibre", montantCentimes: 5999 },
{ structure: "2013ANG", type: "Lien 5G", montantCentimes: 1199 },
{ structure: "2021MOU", type: "Lien fibre", montantCentimes: 5999 },
{ structure: "2063VSJ", type: "Lien fibre", montantCentimes: 5999 },
{ structure: "2069MAU", type: "Lien 5G", montantCentimes: 1199 },
{ structure: "2069SAL", type: "Tél. mobile", montantCentimes: 1199 },
{ structure: "2081ANC", type: "Lien 5G", montantCentimes: 1199 },
{ structure: "2081BLA", type: "Lien 5G", montantCentimes: 0 },
{ structure: "3069UDA", type: "Lien 5G", montantCentimes: 1199 },
{ structure: "3069UDA", type: "Lien fibre", montantCentimes: 27599 },
{ structure: "3069UDA", type: "Tél. mobile", montantCentimes: -1 },
];
const formatMontant = (centimes: number): string => {
const euros = centimes / 100;
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 (euros < 0 ? "-" : "") + parts.join(",") + " EUR";
};
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
const pageW = 210;
const pageH = 297;
const margin = 14;
const tableStartY = 31;
const tableEndY = pageH - 10;
const usableW = pageW - margin * 2;
// En-tête
doc.setFontSize(7);
doc.setFont("helvetica", "normal");
doc.text("Edite le 05/06/2026 - 17:00", pageW - margin, 7, { align: "right" });
doc.setFontSize(13);
doc.setFont("helvetica", "bold");
doc.text("Ventilation facture FREE PRO", pageW / 2, 13, { align: "center" });
doc.setFontSize(10);
doc.text("01/01/2025", pageW / 2, 20, { align: "center" });
doc.setFontSize(8);
doc.setFont("helvetica", "bold");
doc.text("ref_piece :", margin, 27);
doc.setFont("helvetica", "normal");
doc.text("F202501004510", margin + 22, 27);
// Calcul dimensions
const nbRows = lines.length + 2;
const availH = tableEndY - tableStartY;
const rowH = availH / nbRows;
const fontSize = Math.max(5, Math.min(9, Math.floor(rowH * 0.55 / 0.353)));
console.log(`nbRows=${nbRows}, availH=${availH.toFixed(1)}mm, rowH=${rowH.toFixed(2)}mm, fontSize=${fontSize}pt`);
console.log(`Tableau: ${tableStartY}mm → ${(tableStartY + nbRows * rowH).toFixed(1)}mm (limite=${tableEndY}mm)`);
const col0W = usableW * 0.45;
const col1W = usableW * 0.32;
const col2W = usableW * 0.23;
const col1X = margin + col0W;
const col2X = col1X + col1W;
doc.setFontSize(fontSize);
const drawRow = (y: number, c0: string, c1: string, c2: string, bold: boolean, bg?: [number,number,number]) => {
if (bg) { doc.setFillColor(bg[0], bg[1], bg[2]); doc.rect(margin, y, usableW, rowH, "F"); }
doc.setFont("helvetica", bold ? "bold" : "normal");
const textY = y + rowH * 0.65;
const pad = 1.5;
doc.text(c0, margin + pad, textY, { maxWidth: col0W - pad * 2 });
doc.text(c1, col1X + pad, textY, { maxWidth: col1W - pad * 2 });
doc.text(c2, col2X + col2W - pad, textY, { align: "right", maxWidth: col2W - pad * 2 });
};
const drawHLine = (y: number, lw: number, r: number, g: number, b: number) => {
doc.setDrawColor(r, g, b); doc.setLineWidth(lw);
doc.line(margin, y, margin + usableW, y);
};
const drawVLines = (y: number, h: number) => {
doc.setDrawColor(180, 180, 180); doc.setLineWidth(0.1);
doc.line(margin, y, margin, y + h);
doc.line(col1X, y, col1X, y + h);
doc.line(col2X, y, col2X, y + h);
doc.line(margin + usableW, y, margin + usableW, y + h);
};
const headerY = tableStartY;
drawHLine(headerY, 0.4, 0, 0, 0);
drawRow(headerY, "Structure", "Type", "Montant TTC", true);
drawHLine(headerY + rowH, 0.4, 0, 0, 0);
drawVLines(headerY, rowH);
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
const y = headerY + rowH * (i + 1);
const bg: [number,number,number] | undefined = i % 2 === 1 ? [248,248,248] : undefined;
drawRow(y, l.structure, l.type, formatMontant(l.montantCentimes), false, bg);
drawHLine(y + rowH, 0.1, 200, 200, 200);
drawVLines(y, rowH);
}
const totalCentimes = lines.reduce((s, l) => s + l.montantCentimes, 0);
const footerY = headerY + rowH * (lines.length + 1);
drawHLine(footerY, 0.4, 0, 0, 0);
drawRow(footerY, "Total general", "", formatMontant(totalCentimes), true, [240,240,240]);
drawHLine(footerY + rowH, 0.4, 0, 0, 0);
drawVLines(footerY, rowH);
const pdfBytes = doc.output("arraybuffer");
fs.writeFileSync("/tmp/test_freepro_01_25.pdf", Buffer.from(pdfBytes));
console.log("PDF généré : /tmp/test_freepro_01_25.pdf");
console.log(`Nombre de pages : ${doc.getNumberOfPages()}`);