/** * 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 */ } } }