From 7fbb3d5f2c2716ba6035ef1b8601282c25ea88fb Mon Sep 17 00:00:00 2001 From: Manus Date: Wed, 15 Apr 2026 07:35:41 -0400 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20Placement=20intelligent=20du=20ca?= =?UTF-8?q?rtouche=20BAP=20:=20d=C3=A9tection=20de=20zone=20blanche=20par?= =?UTF-8?q?=20rasterisation=20PDF=20(pdftoppm=20+=20sharp),=20cartouche=20?= =?UTF-8?q?positionn=C3=A9=20automatiquement=20dans=20la=20zone=20blanche?= =?UTF-8?q?=20disponible,=20ou=20sur=20une=20nouvelle=20page=20annexe=20si?= =?UTF-8?q?=20aucune=20zone=20suffisante=20n'existe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 6 +- pnpm-lock.yaml | 279 ++++++++++++++++++++++++++++++ server/bapCartouche.ts | 220 ++++++++++++++++++++++++ server/pdfWhiteZone.ts | 217 +++++++++++++++++++++++ server/routers.ts | 378 ++++++++++------------------------------- 5 files changed, 810 insertions(+), 290 deletions(-) create mode 100644 server/bapCartouche.ts create mode 100644 server/pdfWhiteZone.ts diff --git a/package.json b/package.json index c1c59f9..2faa1c7 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "react-pdf": "^10.3.0", "react-resizable-panels": "^3.0.6", "recharts": "^2.15.2", + "sharp": "^0.34.5", "sonner": "^2.0.7", "ssh2-sftp-client": "^12.0.1", "streamdown": "^1.4.0", @@ -131,6 +132,9 @@ }, "overrides": { "tailwindcss>nanoid": "3.3.7" - } + }, + "onlyBuiltDependencies": [ + "sharp" + ] } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 810331f..57a077a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -241,6 +241,9 @@ importers: recharts: specifier: ^2.15.2 version: 2.15.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + sharp: + specifier: ^0.34.5 + version: 0.34.5 sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -640,6 +643,9 @@ packages: '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -1100,6 +1106,143 @@ packages: '@iconify/utils@3.0.2': resolution: {integrity: sha512-EfJS0rLfVuRuJRn4psJHtK2A9TqVnkxPpHY6lYHiB9+8eSuudsxbwMiavocG45ujOo6FJ+CIRlRnlOGinzkaGQ==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -4637,6 +4780,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -5817,6 +5964,11 @@ snapshots: '@drizzle-team/brocli@0.10.2': {} + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -6077,6 +6229,102 @@ snapshots: transitivePeerDependencies: - supports-color + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.9.2 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -10100,6 +10348,37 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.3 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 diff --git a/server/bapCartouche.ts b/server/bapCartouche.ts new file mode 100644 index 0000000..a8cf0f4 --- /dev/null +++ b/server/bapCartouche.ts @@ -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 { + 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; +} diff --git a/server/pdfWhiteZone.ts b/server/pdfWhiteZone.ts new file mode 100644 index 0000000..cb9aa72 --- /dev/null +++ b/server/pdfWhiteZone.ts @@ -0,0 +1,217 @@ +/** + * pdfWhiteZone.ts + * Détecte la plus grande zone blanche disponible sur la dernière page d'un PDF + * pour y placer le cartouche BAP sans masquer d'informations. + * + * Stratégie : + * 1. Rasteriser la dernière page du PDF avec pdftoppm (Poppler) + * 2. Analyser l'image avec sharp pour trouver des bandes horizontales blanches + * 3. Retourner la meilleure position (x, y) en coordonnées PDF (origine bas-gauche) + * 4. Si aucune zone suffisante n'est trouvée, retourner null → le cartouche sera + * ajouté sur une nouvelle page annexe + */ + +import { execFile } from "child_process"; +import { promisify } from "util"; +import * as os from "os"; +import * as path from "path"; +import * as fs from "fs/promises"; +import * as fsSync from "fs"; + +const execFileAsync = promisify(execFile); + +export interface WhiteZoneResult { + /** Coordonnée X en points PDF (origine bas-gauche) */ + x: number; + /** Coordonnée Y en points PDF (origine bas-gauche) */ + y: number; + /** Largeur disponible en points PDF */ + width: number; + /** Hauteur disponible en points PDF */ + height: number; + /** Page cible (0-indexed). -1 = nouvelle page à ajouter */ + pageIndex: number; +} + +/** Seuil de luminosité : pixel considéré "blanc" si tous les canaux >= ce seuil */ +const WHITE_THRESHOLD = 240; +/** Hauteur minimale de la zone blanche requise (en points PDF) */ +const MIN_ZONE_HEIGHT = 155; +/** Largeur minimale de la zone blanche requise (en points PDF) */ +const MIN_ZONE_WIDTH = 400; +/** Marge intérieure de la page (en points PDF) */ +const PAGE_MARGIN = 25; +/** Résolution de rasterisation (DPI) */ +const RASTER_DPI = 150; +/** Facteur de conversion points PDF → pixels à 150 DPI (1 pt = 1/72 inch) */ +const PT_TO_PX = RASTER_DPI / 72; + +/** + * Analyse un buffer PDF et retourne la meilleure zone blanche disponible + * sur la dernière page, ou null si aucune zone suffisante n'existe. + */ +export async function findBestWhiteZone( + pdfBuffer: Buffer, + pageWidth: number, + pageHeight: number, + lastPageIndex: number +): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "bap-zone-")); + const tmpPdf = path.join(tmpDir, "source.pdf"); + const tmpImgBase = path.join(tmpDir, "page"); + + try { + await fs.writeFile(tmpPdf, pdfBuffer); + + // Rasteriser uniquement la dernière page (1-indexed pour pdftoppm) + const pageNum = lastPageIndex + 1; + await execFileAsync("pdftoppm", [ + "-r", String(RASTER_DPI), + "-f", String(pageNum), + "-l", String(pageNum), + "-png", + tmpPdf, + tmpImgBase, + ]); + + // Trouver le fichier image généré + const files = await fs.readdir(tmpDir); + const imgFile = files.find(f => f.endsWith(".png") && f.startsWith("page")); + if (!imgFile) return null; + + const imgPath = path.join(tmpDir, imgFile); + const sharp = (await import("sharp")).default; + + // Charger l'image et obtenir les données brutes RGB + const image = sharp(imgPath); + const meta = await image.metadata(); + const imgWidth = meta.width!; + const imgHeight = meta.height!; + + const { data } = await image + .flatten({ background: { r: 255, g: 255, b: 255 } }) + .raw() + .toBuffer({ resolveWithObject: true }); + + // ── Analyse par bandes horizontales ───────────────────────────────────── + // Pour chaque ligne de pixels, calculer le % de pixels "blancs" + // Une ligne est "blanche" si >= 95% de ses pixels sont blancs + const WHITE_LINE_THRESHOLD = 0.95; + const isWhiteLine = new Uint8Array(imgHeight); + + for (let y = 0; y < imgHeight; y++) { + let whiteCount = 0; + for (let x = 0; x < imgWidth; x++) { + const idx = (y * imgWidth + x) * 3; + const r = data[idx]; + const g = data[idx + 1]; + const b = data[idx + 2]; + if (r >= WHITE_THRESHOLD && g >= WHITE_THRESHOLD && b >= WHITE_THRESHOLD) { + whiteCount++; + } + } + isWhiteLine[y] = whiteCount / imgWidth >= WHITE_LINE_THRESHOLD ? 1 : 0; + } + + // Trouver les blocs de lignes blanches consécutives + interface Band { startY: number; endY: number; heightPx: number } + const bands: Band[] = []; + let bandStart = -1; + + for (let y = 0; y <= imgHeight; y++) { + const isWhite = y < imgHeight ? isWhiteLine[y] === 1 : false; + if (isWhite && bandStart === -1) { + bandStart = y; + } else if (!isWhite && bandStart !== -1) { + bands.push({ startY: bandStart, endY: y - 1, heightPx: y - bandStart }); + bandStart = -1; + } + } + + if (bands.length === 0) return null; + + // Convertir les dimensions minimales en pixels + const minHeightPx = Math.ceil(MIN_ZONE_HEIGHT * PT_TO_PX); + const minWidthPx = Math.ceil(MIN_ZONE_WIDTH * PT_TO_PX); + + // Filtrer les bandes suffisamment hautes + const validBands = bands.filter(b => b.heightPx >= minHeightPx); + if (validBands.length === 0) return null; + + // Vérifier la largeur blanche disponible pour chaque bande valide + // On prend la bande la plus grande (en bas de page de préférence) + // Trier par position Y décroissante (bas de page en premier) puis par hauteur + validBands.sort((a, b) => { + // Préférer le bas de page + const aIsBottom = a.endY > imgHeight * 0.5; + const bIsBottom = b.endY > imgHeight * 0.5; + if (aIsBottom && !bIsBottom) return -1; + if (!aIsBottom && bIsBottom) return 1; + // Sinon, préférer la plus grande + return b.heightPx - a.heightPx; + }); + + for (const band of validBands) { + // Vérifier la largeur blanche disponible dans cette bande + // Analyser les colonnes dans la zone de la bande + const midY = Math.floor((band.startY + band.endY) / 2); + let leftWhite = 0; + let rightWhite = imgWidth - 1; + + // Trouver la marge gauche blanche + for (let x = 0; x < imgWidth; x++) { + const idx = (midY * imgWidth + x) * 3; + if (data[idx] >= WHITE_THRESHOLD && data[idx + 1] >= WHITE_THRESHOLD && data[idx + 2] >= WHITE_THRESHOLD) { + leftWhite = x; + break; + } + } + + // Trouver la marge droite blanche + for (let x = imgWidth - 1; x >= 0; x--) { + const idx = (midY * imgWidth + x) * 3; + if (data[idx] >= WHITE_THRESHOLD && data[idx + 1] >= WHITE_THRESHOLD && data[idx + 2] >= WHITE_THRESHOLD) { + rightWhite = x; + break; + } + } + + const availableWidthPx = rightWhite - leftWhite; + if (availableWidthPx < minWidthPx) continue; + + // Convertir les coordonnées pixels → points PDF + // pdftoppm : y=0 est en haut de l'image, PDF : y=0 est en bas + const pdfX = PAGE_MARGIN; + const pdfWidth = pageWidth - PAGE_MARGIN * 2; + + // band.startY en pixels depuis le haut → convertir en points depuis le bas + const bandTopFromBottom = pageHeight - (band.startY / PT_TO_PX); + const bandBottomFromBottom = pageHeight - (band.endY / PT_TO_PX); + + // On place le cartouche centré verticalement dans la bande + const zoneHeight = Math.min(MIN_ZONE_HEIGHT, bandTopFromBottom - bandBottomFromBottom - 10); + const pdfY = bandBottomFromBottom + 5; // légère marge en bas + + return { + x: pdfX, + y: pdfY, + width: pdfWidth, + height: zoneHeight, + pageIndex: lastPageIndex, + }; + } + + // Aucune zone suffisante trouvée + return null; + } catch (err) { + console.warn("[BAP] Erreur détection zone blanche:", err); + return null; + } finally { + // Nettoyage des fichiers temporaires + try { + const files = await fs.readdir(tmpDir); + await Promise.all(files.map(f => fs.unlink(path.join(tmpDir, f)))); + await fs.rmdir(tmpDir); + } catch (_) { /* ignore */ } + } +} diff --git a/server/routers.ts b/server/routers.ts index 0d56d84..6a5e8fd 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -78,6 +78,7 @@ import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generat import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor"; import { localStoragePut, generateStorageKey } from "./localStorage"; import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport"; +import { drawBapCartouche } from "./bapCartouche"; import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck } from "./emailImportService"; import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService"; import { TRPCError } from "@trpc/server"; @@ -485,105 +486,10 @@ export const appRouter = router({ pdfBytes = Buffer.from(arrayBuffer); } const pdfDoc = await PDFDocument.load(pdfBytes); - const pages = pdfDoc.getPages(); - const lastPage = pages[pages.length - 1]; - const { width, height } = lastPage.getSize(); - // ── Zone blanche BAP (bas de page, hauteur 160pt) ──────────────── - const zoneHeight = 160; - const zoneX = 30; - const zoneY = 10; - const zoneW = width - 60; - - // Fond blanc - lastPage.drawRectangle({ - x: zoneX, - y: zoneY, - width: zoneW, - height: zoneHeight, - color: rgb(1, 1, 1), - borderColor: rgb(0.7, 0.7, 0.7), - borderWidth: 0.5, - }); - - const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold); - const fontNormal = await pdfDoc.embedFont(StandardFonts.Helvetica); - - // Colonne gauche : texte BAP (2/3 de la largeur) - // Colonne droite : signature (1/3 de la largeur) - const leftColW = Math.floor(zoneW * 0.62); - const rightColX = zoneX + leftColW + 10; - const rightColW = zoneW - leftColW - 20; - - // Ligne 1 : CAPEX/OPEX (type achat) - const typeAchatText = (invoice.typeAchat || 'N/A').toUpperCase(); - lastPage.drawText(typeAchatText, { - x: zoneX + 10, - y: zoneY + zoneHeight - 22, - size: 11, - font, - color: rgb(0.1, 0.1, 0.5), - }); - - // Ligne 2 : BON À PAYER (en vert, plus grand) - lastPage.drawText('BON À PAYER', { - x: zoneX + 10, - y: zoneY + zoneHeight - 42, - size: 14, - font, - color: rgb(0, 0.5, 0), - }); - - // Ligne 3 : Destinataire - const recipientRaw = (invoice as any).recipientName || ''; - const destinataireText = recipientRaw ? recipientRaw : 'TOUS'; - lastPage.drawText(destinataireText, { - x: zoneX + 10, - y: zoneY + zoneHeight - 62, - size: 10, - font: fontNormal, - color: rgb(0.2, 0.2, 0.2), - }); - - // Séparateur horizontal - lastPage.drawLine({ - start: { x: zoneX + 10, y: zoneY + zoneHeight - 72 }, - end: { x: zoneX + leftColW - 10, y: zoneY + zoneHeight - 72 }, - thickness: 0.5, - color: rgb(0.7, 0.7, 0.7), - }); - - // Ligne 4 : Service + Ventilation - const line2 = `Service : ${invoice.serviceConcerne || '-'} | Ventilation : ${invoice.ventilationComptable || '-'}`; - lastPage.drawText(line2, { - x: zoneX + 10, - y: zoneY + zoneHeight - 88, - size: 9, - font: fontNormal, - color: rgb(0.2, 0.2, 0.2), - }); - - // Ligne 5 : Date de validation - const now = new Date(); - const dateStr = now.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }); - const timeStr = now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); - lastPage.drawText(`Validé le ${dateStr} à ${timeStr}`, { - x: zoneX + 10, - y: zoneY + zoneHeight - 104, - size: 8, - font: fontNormal, - color: rgb(0.4, 0.4, 0.4), - }); - - // Séparateur vertical entre colonne gauche et droite - lastPage.drawLine({ - start: { x: zoneX + leftColW, y: zoneY + 10 }, - end: { x: zoneX + leftColW, y: zoneY + zoneHeight - 10 }, - thickness: 0.5, - color: rgb(0.8, 0.8, 0.8), - }); - - // ── Signature du service ────────────────────────────────────────── + // ── Récupération de la signature du service ─────────────────────── + let sigBytesForCartouche: Buffer | undefined; + let sigMimeForCartouche: "image/png" | "image/jpeg" | undefined; const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id); const serviceName = invoice.serviceConcerne || ''; const assoc = serviceAssociations.find( @@ -594,12 +500,10 @@ export const appRouter = router({ if (sig) { signatureName = `${sig.firstName} ${sig.lastName}`; try { - let sigImageBytes: Buffer; const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey); try { - sigImageBytes = await fs.readFile(sigImagePath); + sigBytesForCartouche = await fs.readFile(sigImagePath); } catch (_sigLocalErr) { - // Fichier signature non disponible localement → télécharger depuis l'URL const sigUrl = sig.imageUrl; if (!sigUrl) throw new Error('Image signature introuvable'); let absoluteSigUrl = sigUrl; @@ -609,39 +513,25 @@ export const appRouter = router({ } const sigResp = await fetch(absoluteSigUrl); if (!sigResp.ok) throw new Error(`Impossible de télécharger la signature: ${sigResp.status}`); - sigImageBytes = Buffer.from(await sigResp.arrayBuffer()); + sigBytesForCartouche = Buffer.from(await sigResp.arrayBuffer()); } - const mimeType = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; - let embeddedSig; - if (mimeType === 'image/png') { - embeddedSig = await pdfDoc.embedPng(sigImageBytes); - } else { - embeddedSig = await pdfDoc.embedJpg(sigImageBytes); - } - // Signature dans la colonne droite, centrée verticalement - const sigWidth = Math.min(rightColW - 10, 110); - const sigHeight = Math.round(sigWidth * 0.45); - const sigX = rightColX + (rightColW - sigWidth) / 2; - const sigY = zoneY + 35; - lastPage.drawImage(embeddedSig, { - x: sigX, - y: sigY, - width: sigWidth, - height: sigHeight, - }); - // Nom du signataire centré sous la signature - const nameW = fontNormal.widthOfTextAtSize(signatureName, 8); - lastPage.drawText(signatureName, { - x: sigX + (sigWidth - nameW) / 2, - y: zoneY + 22, - size: 8, - font: fontNormal, - color: rgb(0.3, 0.3, 0.3), - }); + sigMimeForCartouche = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; } catch (_) { /* ignore signature errors */ } } } + // ── Placement intelligent du cartouche BAP ──────────────────────── + await drawBapCartouche(pdfDoc, pdfBytes, { + typeAchat: invoice.typeAchat || 'N/A', + destinataire: (invoice as any).recipientName || 'TOUS', + serviceConcerne: invoice.serviceConcerne || '-', + ventilationComptable: invoice.ventilationComptable || '-', + validatedAt: new Date(), + signatureImageBytes: sigBytesForCartouche, + signatureMimeType: sigMimeForCartouche, + signatureName: signatureName || undefined, + }); + const signedPdfBytes = await pdfDoc.save(); const filename = path.basename(invoice.fileKey); const bapFilename = `BAP_${Date.now()}_${filename}`; @@ -750,114 +640,51 @@ export const appRouter = router({ sourcePdfBytes = Buffer.from(await resp.arrayBuffer()); } const pdfDoc = await PDFDocument.load(sourcePdfBytes); - const pages = pdfDoc.getPages(); - const lastPage = pages[pages.length - 1]; - const { width, height } = lastPage.getSize(); - const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); - const fontNormal = await pdfDoc.embedFont(StandardFonts.Helvetica); - // Zone BAP en bas à droite, layout 2 colonnes - const zoneH = 160; - const zoneW = width - 60; - const zoneX = 30; - const zoneY = 10; - const leftColW = Math.floor(zoneW * 0.62); - const rightColX = zoneX + leftColW + 10; - const rightColW = zoneW - leftColW - 20; - lastPage.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 : Type achat - const typeAchatText = (invoice.typeAchat || '').toUpperCase(); - lastPage.drawText(typeAchatText, { - x: zoneX + 10, y: zoneY + zoneH - 22, - size: 11, font: fontBold, color: rgb(0.1, 0.1, 0.5), - }); - // Ligne 2 : BON À PAYER - lastPage.drawText('BON À PAYER', { - x: zoneX + 10, y: zoneY + zoneH - 42, - size: 14, font: fontBold, color: rgb(0, 0.5, 0), - }); - // Ligne 3 : Destinataire - const recipientText = (invoice as any).recipientName || 'TOUS'; - lastPage.drawText(recipientText, { - x: zoneX + 10, y: zoneY + zoneH - 62, - size: 10, font: fontNormal, color: rgb(0.2, 0.2, 0.2), - }); - // Séparateur horizontal - lastPage.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 - const serviceVentLine = `Service : ${invoice.serviceConcerne || '-'} | Ventilation : ${invoice.ventilationComptable || '-'}`; - lastPage.drawText(serviceVentLine, { - 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 bulkNow = new Date(); - const bulkDateStr = bulkNow.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }); - const bulkTimeStr = bulkNow.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); - lastPage.drawText(`Validé le ${bulkDateStr} à ${bulkTimeStr}`, { - x: zoneX + 10, y: zoneY + zoneH - 104, - size: 8, font: fontNormal, color: rgb(0.4, 0.4, 0.4), - }); - // Séparateur vertical - lastPage.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 du service - const serviceName = invoice.serviceConcerne || ''; - const assoc = serviceSignaturesList.find( - a => a.serviceName.toLowerCase() === serviceName.toLowerCase() + + // ── Récupération de la signature du service ─────────────────────── + let sigBytesForCartouche2: Buffer | undefined; + let sigMimeForCartouche2: "image/png" | "image/jpeg" | undefined; + const serviceName2 = invoice.serviceConcerne || ''; + const assoc2 = serviceSignaturesList.find( + a => a.serviceName.toLowerCase() === serviceName2.toLowerCase() ); - if (assoc) { - const sig = await getSignatureById(assoc.signatureId); - if (sig) { - signatureName = `${sig.firstName} ${sig.lastName}`; + if (assoc2) { + const sig2 = await getSignatureById(assoc2.signatureId); + if (sig2) { + signatureName = `${sig2.firstName} ${sig2.lastName}`; try { - let sigImageBytes: Buffer; - const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey); + const sigImagePath2 = path.join(STORAGE_BASE_PATH, sig2.imageKey); try { - sigImageBytes = await fs.readFile(sigImagePath); + sigBytesForCartouche2 = await fs.readFile(sigImagePath2); } catch (_) { - const sigUrl = sig.imageUrl; - if (!sigUrl) throw new Error('Image signature introuvable'); - let absoluteSigUrl = sigUrl; - if (sigUrl.startsWith('/')) { + const sigUrl2 = sig2.imageUrl; + if (!sigUrl2) throw new Error('Image signature introuvable'); + let absoluteSigUrl2 = sigUrl2; + if (sigUrl2.startsWith('/')) { const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`; - absoluteSigUrl = `${baseUrl}${sigUrl}`; + absoluteSigUrl2 = `${baseUrl}${sigUrl2}`; } - const sigResp = await fetch(absoluteSigUrl); - if (!sigResp.ok) throw new Error(`HTTP ${sigResp.status}`); - sigImageBytes = Buffer.from(await sigResp.arrayBuffer()); + const sigResp2 = await fetch(absoluteSigUrl2); + if (!sigResp2.ok) throw new Error(`HTTP ${sigResp2.status}`); + sigBytesForCartouche2 = Buffer.from(await sigResp2.arrayBuffer()); } - const mimeType = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; - const embeddedSig = mimeType === 'image/png' - ? await pdfDoc.embedPng(sigImageBytes) - : await pdfDoc.embedJpg(sigImageBytes); - // Signature dans la colonne droite, centrée - const bulkSigWidth = Math.min(rightColW - 10, 110); - const bulkSigHeight = Math.round(bulkSigWidth * 0.45); - const bulkSigX = rightColX + (rightColW - bulkSigWidth) / 2; - const bulkSigY = zoneY + 35; - lastPage.drawImage(embeddedSig, { - x: bulkSigX, y: bulkSigY, width: bulkSigWidth, height: bulkSigHeight, - }); - const bulkNameW = fontNormal.widthOfTextAtSize(signatureName, 8); - lastPage.drawText(signatureName, { - x: bulkSigX + (bulkSigWidth - bulkNameW) / 2, y: zoneY + 22, - size: 8, font: fontNormal, color: rgb(0.3, 0.3, 0.3), - }); - } catch (_) { /* ignore signature errors */ } + sigMimeForCartouche2 = sig2.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; + } catch (_) { /* ignore */ } } } + + // ── Placement intelligent du cartouche BAP ──────────────────────── + await drawBapCartouche(pdfDoc, sourcePdfBytes, { + typeAchat: invoice.typeAchat || '', + destinataire: (invoice as any).recipientName || 'TOUS', + serviceConcerne: invoice.serviceConcerne || '-', + ventilationComptable: invoice.ventilationComptable || '-', + validatedAt: validatedAt, + signatureImageBytes: sigBytesForCartouche2, + signatureMimeType: sigMimeForCartouche2, + signatureName: signatureName || undefined, + }); + const signedPdfBytes = await pdfDoc.save(); const filename = path.basename(invoice.fileKey); const bapFilename = `BAP_${Date.now()}_${filename}`; @@ -1009,78 +836,51 @@ export const appRouter = router({ } const pdfDoc = await PDFDocument.load(pdfBytes); - const pages = pdfDoc.getPages(); - const lastPage = pages[pages.length - 1]; - const { width } = lastPage.getSize(); - const zoneHeight = 160; - const zoneX = 30; - const zoneY = 10; - const zoneW = width - 60; - - lastPage.drawRectangle({ - x: zoneX, y: zoneY, width: zoneW, height: zoneHeight, - color: rgb(1, 1, 1), borderColor: rgb(0.7, 0.7, 0.7), borderWidth: 0.5, - }); - - 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; - - // Utiliser les données de l'entrée bapHistory - const typeAchatText = (entry.typeAchat || 'N/A').toUpperCase(); - lastPage.drawText(typeAchatText, { x: zoneX + 10, y: zoneY + zoneHeight - 22, size: 11, font, color: rgb(0.1, 0.1, 0.5) }); - lastPage.drawText('BON À PAYER', { x: zoneX + 10, y: zoneY + zoneHeight - 42, size: 14, font, color: rgb(0, 0.5, 0) }); - const destinataireText = entry.recipientName || 'TOUS'; - lastPage.drawText(destinataireText, { x: zoneX + 10, y: zoneY + zoneHeight - 62, size: 10, font: fontNormal, color: rgb(0.2, 0.2, 0.2) }); - lastPage.drawLine({ start: { x: zoneX + 10, y: zoneY + zoneHeight - 72 }, end: { x: zoneX + leftColW - 10, y: zoneY + zoneHeight - 72 }, thickness: 0.5, color: rgb(0.7, 0.7, 0.7) }); - const line2 = `Service : ${entry.serviceConcerne || '-'} | Ventilation : ${entry.ventilationComptable || '-'}`; - lastPage.drawText(line2, { x: zoneX + 10, y: zoneY + zoneHeight - 88, size: 9, font: fontNormal, color: rgb(0.2, 0.2, 0.2) }); - const validatedDate = entry.validatedAt ? new Date(entry.validatedAt) : new Date(); - const dateStr = validatedDate.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }); - const timeStr = validatedDate.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); - lastPage.drawText(`Validé le ${dateStr} à ${timeStr}`, { x: zoneX + 10, y: zoneY + zoneHeight - 104, size: 8, font: fontNormal, color: rgb(0.4, 0.4, 0.4) }); - lastPage.drawLine({ start: { x: zoneX + leftColW, y: zoneY + 10 }, end: { x: zoneX + leftColW, y: zoneY + zoneHeight - 10 }, thickness: 0.5, color: rgb(0.8, 0.8, 0.8) }); - - // Signature - let signatureName: string | null = entry.signatureName || null; + // ── Récupération de la signature du service ─────────────────────── + let sigBytesForCartouche3: Buffer | undefined; + let sigMimeForCartouche3: "image/png" | "image/jpeg" | undefined; + let signatureName3: string | null = entry.signatureName || null; if (entry.serviceConcerne) { - const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id); - const assoc = serviceAssociations.find(a => a.serviceName.toLowerCase() === (entry.serviceConcerne || '').toLowerCase()); - if (assoc) { - const sig = await getSignatureById(assoc.signatureId); - if (sig) { - signatureName = `${sig.firstName} ${sig.lastName}`; + const serviceAssociations3 = await getServiceSignaturesByUser(ctx.user.id); + const assoc3 = serviceAssociations3.find(a => a.serviceName.toLowerCase() === (entry.serviceConcerne || '').toLowerCase()); + if (assoc3) { + const sig3 = await getSignatureById(assoc3.signatureId); + if (sig3) { + signatureName3 = `${sig3.firstName} ${sig3.lastName}`; try { - let sigImageBytes: Buffer; - const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey); - try { sigImageBytes = await fs.readFile(sigImagePath); } catch (_) { - const sigUrl = sig.imageUrl; - if (!sigUrl) throw new Error('Image signature introuvable'); - let absoluteSigUrl = sigUrl; - if (sigUrl.startsWith('/')) { + const sigImagePath3 = path.join(STORAGE_BASE_PATH, sig3.imageKey); + try { sigBytesForCartouche3 = await fs.readFile(sigImagePath3); } catch (_) { + const sigUrl3 = sig3.imageUrl; + if (!sigUrl3) throw new Error('Image signature introuvable'); + let absoluteSigUrl3 = sigUrl3; + if (sigUrl3.startsWith('/')) { const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`; - absoluteSigUrl = `${baseUrl}${sigUrl}`; + absoluteSigUrl3 = `${baseUrl}${sigUrl3}`; } - const sigResp = await fetch(absoluteSigUrl); - if (!sigResp.ok) throw new Error('Impossible de télécharger la signature'); - sigImageBytes = Buffer.from(await sigResp.arrayBuffer()); + const sigResp3 = await fetch(absoluteSigUrl3); + if (!sigResp3.ok) throw new Error('Impossible de télécharger la signature'); + sigBytesForCartouche3 = Buffer.from(await sigResp3.arrayBuffer()); } - const mimeType = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; - const embeddedSig = mimeType === 'image/png' ? await pdfDoc.embedPng(sigImageBytes) : await pdfDoc.embedJpg(sigImageBytes); - const sigWidth = Math.min(rightColW - 10, 110); - const sigHeight = Math.round(sigWidth * 0.45); - const sigX = rightColX + (rightColW - sigWidth) / 2; - lastPage.drawImage(embeddedSig, { x: sigX, y: zoneY + 35, width: sigWidth, height: sigHeight }); - const nameW = fontNormal.widthOfTextAtSize(signatureName, 8); - lastPage.drawText(signatureName, { x: sigX + (sigWidth - nameW) / 2, y: zoneY + 22, size: 8, font: fontNormal, color: rgb(0.3, 0.3, 0.3) }); + sigMimeForCartouche3 = sig3.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; } catch (_) { /* ignore */ } } } } + // ── Placement intelligent du cartouche BAP ──────────────────────── + const validatedDate3 = entry.validatedAt ? new Date(entry.validatedAt) : new Date(); + await drawBapCartouche(pdfDoc, pdfBytes, { + typeAchat: entry.typeAchat || 'N/A', + destinataire: entry.recipientName || 'TOUS', + serviceConcerne: entry.serviceConcerne || '-', + ventilationComptable: entry.ventilationComptable || '-', + validatedAt: validatedDate3, + signatureImageBytes: sigBytesForCartouche3, + signatureMimeType: sigMimeForCartouche3, + signatureName: signatureName3 || undefined, + }); + const signedPdfBytes = await pdfDoc.save(); const filename = path.basename(invoice.fileKey || `invoice_${invoice.id}.pdf`); const bapFilename = `BAP_${Date.now()}_${filename}`;