Files
demat-facturation/server/pdfWhiteZone.ts

218 lines
7.6 KiB
TypeScript

/**
* 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<WhiteZoneResult | null> {
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 */ }
}
}