fix: PDF généré côté serveur (pdf-lib) - 1 page A4 portrait garantie
This commit is contained in:
267
server/freeproPdfService.ts
Normal file
267
server/freeproPdfService.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user