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:
217
server/pdfWhiteZone.ts
Normal file
217
server/pdfWhiteZone.ts
Normal file
@@ -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<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 */ }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user