Checkpoint: Placement intelligent du cartouche BAP : détection de zone blanche par rasterisation PDF (pdftoppm + sharp), cartouche positionné automatiquement dans la zone blanche disponible, ou sur une nouvelle page annexe si aucune zone suffisante n'existe
This commit is contained in:
220
server/bapCartouche.ts
Normal file
220
server/bapCartouche.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* bapCartouche.ts
|
||||
* Helper centralisé pour dessiner le cartouche BAP sur un PDF.
|
||||
* Utilise la détection de zone blanche pour positionner le cartouche
|
||||
* sans masquer d'informations importantes.
|
||||
*/
|
||||
|
||||
import type { PDFDocument, PDFPage } from "pdf-lib";
|
||||
import { findBestWhiteZone } from "./pdfWhiteZone";
|
||||
|
||||
export interface BapCartoucheData {
|
||||
typeAchat: string;
|
||||
destinataire: string;
|
||||
serviceConcerne: string;
|
||||
ventilationComptable: string;
|
||||
validatedAt: Date;
|
||||
signatureImageBytes?: Buffer;
|
||||
signatureMimeType?: "image/png" | "image/jpeg";
|
||||
signatureName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dessine le cartouche BAP sur le PDF.
|
||||
* Détecte automatiquement une zone blanche disponible.
|
||||
* Si aucune zone n'est trouvée, ajoute une nouvelle page annexe.
|
||||
*
|
||||
* @returns Le PDFDocument modifié (même référence)
|
||||
*/
|
||||
export async function drawBapCartouche(
|
||||
pdfDoc: PDFDocument,
|
||||
pdfBuffer: Buffer,
|
||||
data: BapCartoucheData
|
||||
): Promise<PDFDocument> {
|
||||
const { rgb, StandardFonts } = await import("pdf-lib");
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
const lastPageIndex = pages.length - 1;
|
||||
const lastPage = pages[lastPageIndex];
|
||||
const { width: pageWidth, height: pageHeight } = lastPage.getSize();
|
||||
|
||||
const ZONE_HEIGHT = 160;
|
||||
const ZONE_MARGIN = 28;
|
||||
const ZONE_WIDTH = pageWidth - ZONE_MARGIN * 2;
|
||||
|
||||
// ── Détection de la zone blanche ─────────────────────────────────────────
|
||||
let targetPage: PDFPage;
|
||||
let zoneX: number;
|
||||
let zoneY: number;
|
||||
let zoneW: number;
|
||||
let zoneH: number;
|
||||
let isNewPage = false;
|
||||
|
||||
const whiteZone = await findBestWhiteZone(pdfBuffer, pageWidth, pageHeight, lastPageIndex);
|
||||
|
||||
if (whiteZone && whiteZone.height >= 140) {
|
||||
// Zone blanche trouvée sur la dernière page
|
||||
targetPage = pages[whiteZone.pageIndex];
|
||||
zoneX = whiteZone.x;
|
||||
zoneY = whiteZone.y;
|
||||
zoneW = whiteZone.width;
|
||||
zoneH = Math.min(ZONE_HEIGHT, whiteZone.height);
|
||||
} else {
|
||||
// Aucune zone suffisante → ajouter une nouvelle page
|
||||
targetPage = pdfDoc.addPage([pageWidth, pageHeight]);
|
||||
zoneX = ZONE_MARGIN;
|
||||
zoneY = pageHeight - ZONE_HEIGHT - 40;
|
||||
zoneW = ZONE_WIDTH;
|
||||
zoneH = ZONE_HEIGHT;
|
||||
isNewPage = true;
|
||||
|
||||
// Sur la nouvelle page, ajouter un en-tête discret
|
||||
const fontHeader = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
targetPage.drawText("Annexe BAP — Bon à Payer", {
|
||||
x: ZONE_MARGIN,
|
||||
y: pageHeight - 30,
|
||||
size: 10,
|
||||
font: fontHeader,
|
||||
color: rgb(0.5, 0.5, 0.5),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Dessin du cartouche ───────────────────────────────────────────────────
|
||||
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
const fontNormal = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
|
||||
const leftColW = Math.floor(zoneW * 0.62);
|
||||
const rightColX = zoneX + leftColW + 10;
|
||||
const rightColW = zoneW - leftColW - 20;
|
||||
|
||||
// Fond blanc avec bordure
|
||||
targetPage.drawRectangle({
|
||||
x: zoneX,
|
||||
y: zoneY,
|
||||
width: zoneW,
|
||||
height: zoneH,
|
||||
color: rgb(1, 1, 1),
|
||||
borderColor: rgb(0.2, 0.2, 0.2),
|
||||
borderWidth: 1,
|
||||
});
|
||||
|
||||
// Ligne 1 : CAPEX/OPEX
|
||||
targetPage.drawText(data.typeAchat.toUpperCase(), {
|
||||
x: zoneX + 10,
|
||||
y: zoneY + zoneH - 22,
|
||||
size: 11,
|
||||
font,
|
||||
color: rgb(0.1, 0.1, 0.5),
|
||||
});
|
||||
|
||||
// Ligne 2 : BON À PAYER
|
||||
targetPage.drawText("BON À PAYER", {
|
||||
x: zoneX + 10,
|
||||
y: zoneY + zoneH - 42,
|
||||
size: 14,
|
||||
font,
|
||||
color: rgb(0, 0.5, 0),
|
||||
});
|
||||
|
||||
// Ligne 3 : Destinataire
|
||||
targetPage.drawText(data.destinataire || "TOUS", {
|
||||
x: zoneX + 10,
|
||||
y: zoneY + zoneH - 62,
|
||||
size: 10,
|
||||
font: fontNormal,
|
||||
color: rgb(0.2, 0.2, 0.2),
|
||||
});
|
||||
|
||||
// Séparateur horizontal
|
||||
targetPage.drawLine({
|
||||
start: { x: zoneX + 10, y: zoneY + zoneH - 72 },
|
||||
end: { x: zoneX + leftColW - 10, y: zoneY + zoneH - 72 },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.7, 0.7, 0.7),
|
||||
});
|
||||
|
||||
// Ligne 4 : Service + Ventilation
|
||||
targetPage.drawText(
|
||||
`Service : ${data.serviceConcerne || "-"} | Ventilation : ${data.ventilationComptable || "-"}`,
|
||||
{
|
||||
x: zoneX + 10,
|
||||
y: zoneY + zoneH - 88,
|
||||
size: 9,
|
||||
font: fontNormal,
|
||||
color: rgb(0.2, 0.2, 0.2),
|
||||
}
|
||||
);
|
||||
|
||||
// Ligne 5 : Date de validation
|
||||
const dateStr = data.validatedAt.toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
const timeStr = data.validatedAt.toLocaleTimeString("fr-FR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
targetPage.drawText(`Validé le ${dateStr} à ${timeStr}`, {
|
||||
x: zoneX + 10,
|
||||
y: zoneY + zoneH - 104,
|
||||
size: 8,
|
||||
font: fontNormal,
|
||||
color: rgb(0.4, 0.4, 0.4),
|
||||
});
|
||||
|
||||
// Note si nouvelle page
|
||||
if (isNewPage) {
|
||||
targetPage.drawText("(cartouche ajouté sur page annexe — facture complète sur page précédente)", {
|
||||
x: zoneX + 10,
|
||||
y: zoneY + zoneH - 120,
|
||||
size: 7,
|
||||
font: fontNormal,
|
||||
color: rgb(0.6, 0.6, 0.6),
|
||||
});
|
||||
}
|
||||
|
||||
// Séparateur vertical
|
||||
targetPage.drawLine({
|
||||
start: { x: zoneX + leftColW, y: zoneY + 10 },
|
||||
end: { x: zoneX + leftColW, y: zoneY + zoneH - 10 },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8),
|
||||
});
|
||||
|
||||
// ── Signature ─────────────────────────────────────────────────────────────
|
||||
if (data.signatureImageBytes && data.signatureName) {
|
||||
try {
|
||||
const mimeType = data.signatureMimeType || "image/png";
|
||||
const embeddedSig =
|
||||
mimeType === "image/png"
|
||||
? await pdfDoc.embedPng(data.signatureImageBytes)
|
||||
: await pdfDoc.embedJpg(data.signatureImageBytes);
|
||||
|
||||
const sigWidth = Math.min(rightColW - 10, 110);
|
||||
const sigHeight = Math.round(sigWidth * 0.45);
|
||||
const sigX = rightColX + (rightColW - sigWidth) / 2;
|
||||
const sigY = zoneY + 35;
|
||||
|
||||
targetPage.drawImage(embeddedSig, {
|
||||
x: sigX,
|
||||
y: sigY,
|
||||
width: sigWidth,
|
||||
height: sigHeight,
|
||||
});
|
||||
|
||||
const nameW = fontNormal.widthOfTextAtSize(data.signatureName, 8);
|
||||
targetPage.drawText(data.signatureName, {
|
||||
x: sigX + (sigWidth - nameW) / 2,
|
||||
y: zoneY + 22,
|
||||
size: 8,
|
||||
font: fontNormal,
|
||||
color: rgb(0.3, 0.3, 0.3),
|
||||
});
|
||||
} catch (_) {
|
||||
/* ignore signature errors */
|
||||
}
|
||||
}
|
||||
|
||||
return pdfDoc;
|
||||
}
|
||||
Reference in New Issue
Block a user