- Remplacer Cloudinary (US) par solution 100% AWS eu-west-3 - Lambda odentas-sign-pdf-converter avec pdftoppm - Lambda Layer poppler-utils v5 avec dépendances complètes - Trigger S3 ObjectCreated pour conversion automatique - Support multi-pages validé (PDF 3 pages) - Stockage images dans S3 odentas-docs - PDFImageViewer pour affichage images converties - Conformité RGPD garantie (données EU uniquement)
126 lines
3.7 KiB
TypeScript
126 lines
3.7 KiB
TypeScript
// Utilities to extract DocuSeal-style signature placeholders from PDFs
|
|
// and estimate reasonable positions when exact text coordinates are unavailable.
|
|
|
|
export type PlaceholderMatch = {
|
|
fullMatch: string;
|
|
label: string;
|
|
role: string;
|
|
type: string;
|
|
width: number; // mm
|
|
height: number; // mm
|
|
startIndex: number;
|
|
endIndex: number;
|
|
};
|
|
|
|
export type EstimatedPosition = {
|
|
role: string;
|
|
label: string;
|
|
page: number; // 1-indexed
|
|
x: number; // mm from left
|
|
y: number; // mm from top
|
|
width: number; // mm
|
|
height: number; // mm
|
|
};
|
|
|
|
const PLACEHOLDER_REGEX = /\{\{([^;]+);role=([^;]+);type=([^;]+);height=(\d+);width=(\d+)\}\}/g;
|
|
|
|
/**
|
|
* Count PDF pages by scanning for '/Type /Page' markers in raw bytes.
|
|
* This is heuristic but robust enough for most PDFs without full parsing.
|
|
*/
|
|
export function countPdfPagesFromBytes(bytes: Uint8Array | Buffer): number {
|
|
try {
|
|
const text = bufferToLatin1String(bytes);
|
|
// Count '/Type /Page' but not '/Type /Pages'
|
|
const matches = text.match(/\/Type\s*\/Page(?!s)\b/g);
|
|
if (matches && matches.length > 0) return matches.length;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
// Default to 1 page if unknown
|
|
return 1;
|
|
}
|
|
|
|
/**
|
|
* Extract placeholders from PDF bytes by regex scanning the raw stream text.
|
|
*/
|
|
export function extractPlaceholdersFromPdfBuffer(bytes: Uint8Array | Buffer): PlaceholderMatch[] {
|
|
const text = bufferToLatin1String(bytes);
|
|
const placeholders: PlaceholderMatch[] = [];
|
|
|
|
PLACEHOLDER_REGEX.lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = PLACEHOLDER_REGEX.exec(text)) !== null) {
|
|
placeholders.push({
|
|
fullMatch: match[0],
|
|
label: match[1].trim(),
|
|
role: match[2].trim(),
|
|
type: match[3].trim(),
|
|
height: parseInt(match[4], 10),
|
|
width: parseInt(match[5], 10),
|
|
startIndex: match.index,
|
|
endIndex: match.index + match[0].length,
|
|
});
|
|
}
|
|
|
|
return placeholders;
|
|
}
|
|
|
|
/**
|
|
* Estimate reasonable positions (in mm) for placeholders when exact coordinates are unknown.
|
|
* Assumes A4 portrait (210 x 297mm). Places fields near the bottom margin, left/right by role.
|
|
*/
|
|
export function estimatePositionsFromPlaceholders(
|
|
placeholders: PlaceholderMatch[],
|
|
pageCount: number
|
|
): EstimatedPosition[] {
|
|
const A4_WIDTH_MM = 210;
|
|
const A4_HEIGHT_MM = 297;
|
|
const MARGIN_X_MM = 20;
|
|
const MARGIN_BOTTOM_MM = 30;
|
|
|
|
// Prefer placing on the last page by default
|
|
const defaultPage = Math.max(1, pageCount);
|
|
|
|
return placeholders.map((ph) => {
|
|
// Les placeholders portent déjà les dimensions attendues du cadre
|
|
// de signature (en millimètres), on les utilise telles quelles.
|
|
const width = Math.max(20, ph.width || 150); // mm
|
|
const height = Math.max(10, ph.height || 60); // mm
|
|
|
|
// Default Y: bottom area
|
|
const y = A4_HEIGHT_MM - MARGIN_BOTTOM_MM - height;
|
|
|
|
// Role-based horizontal placement: employer left, employee right
|
|
const roleLc = ph.role.toLowerCase();
|
|
const isEmployee = roleLc.includes('salari') || roleLc.includes('employé') || roleLc.includes('employe');
|
|
|
|
const x = isEmployee
|
|
? Math.max(MARGIN_X_MM, A4_WIDTH_MM - MARGIN_X_MM - width)
|
|
: MARGIN_X_MM;
|
|
|
|
return {
|
|
role: ph.role,
|
|
label: ph.label,
|
|
page: defaultPage,
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
};
|
|
});
|
|
}
|
|
|
|
function bufferToLatin1String(bytes: Uint8Array | Buffer): string {
|
|
if (typeof Buffer !== 'undefined' && (bytes as Buffer).toString) {
|
|
return (bytes as Buffer).toString('latin1');
|
|
}
|
|
// Fallback for environments without Node Buffer
|
|
let result = '';
|
|
const chunk = 8192;
|
|
for (let i = 0; i < bytes.length; i += chunk) {
|
|
const slice = bytes.slice(i, i + chunk);
|
|
result += Array.from(slice as any, (b: number) => String.fromCharCode(b)).join('');
|
|
}
|
|
return result;
|
|
}
|