Files
demat-facturation/server/freeproPdfService.ts

268 lines
8.1 KiB
TypeScript

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