268 lines
8.1 KiB
TypeScript
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();
|
|
}
|