@aidalinfo/pdf-processor
Version:
Powerful PDF data extraction library powered by AI vision models. Transform PDFs into structured, validated data using TypeScript, Zod, and AI providers like Scaleway and Ollama.
1,312 lines (1,281 loc) • 53.7 kB
JavaScript
import { createRequire } from "node:module";
var __create = Object.create;
var __getProtoOf = Object.getPrototypeOf;
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __toESM = (mod, isNodeMode, target) => {
target = mod != null ? __create(__getProtoOf(mod)) : {};
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
for (let key of __getOwnPropNames(mod))
if (!__hasOwnProp.call(to, key))
__defProp(to, key, {
get: () => mod[key],
enumerable: true
});
return to;
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, {
get: all[name],
enumerable: true,
configurable: true,
set: (newValue) => all[name] = () => newValue
});
};
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
var __require = /* @__PURE__ */ createRequire(import.meta.url);
// src/utils/logger.ts
import pino from "pino";
function createModuleLogger(module) {
return logger.child({ module });
}
var isDevelopment, pinoConfig, logger;
var init_logger = __esm(() => {
isDevelopment = process.env.EK_NODE_ENV === "development" || process.env.EK_NODE_ENV !== "production";
pinoConfig = {
level: process.env.EK_LOG_LEVEL || (isDevelopment ? "debug" : "info"),
...isDevelopment ? {
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "SYS:HH:MM:ss.l",
ignore: "pid,hostname",
singleLine: true
}
}
} : {
formatters: {
level: (label) => {
return { level: label.toUpperCase() };
}
},
timestamp: pino.stdTimeFunctions.isoTime
}
};
logger = pino(pinoConfig);
});
// src/core/file-processor.ts
var exports_file_processor = {};
__export(exports_file_processor, {
extractImagesFromPDF: () => extractImagesFromPDF,
createTempDir: () => createTempDir,
cleanupTempDir: () => cleanupTempDir,
cleanupAllTempDirs: () => cleanupAllTempDirs
});
import fs from "fs/promises";
import path from "path";
import sharp from "sharp";
import os from "os";
import { existsSync } from "fs";
import * as gs from "ghostscript-node";
async function extractImagesFromPDF(pdfPath, outputDir, options) {
try {
if (!existsSync(pdfPath)) {
throw new Error(`PDF file does not exist: ${pdfPath}`);
}
const pdfBuffer = await fs.readFile(pdfPath);
const isValid = await gs.isValidPDF(pdfBuffer);
if (!isValid) {
throw new Error("The PDF file is not valid");
}
const imageBuffers = await gs.renderPDFPagesToPNG(pdfBuffer, undefined, undefined, options.dpi || 300);
logger2.info({ pageCount: imageBuffers.length }, "\uD83D\uDCC4 PDF contient pages");
const saveStart = Date.now();
logger2.debug({ imageCount: imageBuffers.length }, "\uD83D\uDCBE Sauvegarde des images sur disque...");
const imagePaths = new Array(imageBuffers.length);
await Promise.all(imageBuffers.map(async (buffer, i) => {
const imagePath = path.join(outputDir, `page-${i + 1}.png`);
await fs.writeFile(imagePath, buffer);
imagePaths[i] = imagePath;
}));
const saveTime = Date.now() - saveStart;
logger2.debug({ saveTime }, "Sauvegarde terminée");
logger2.info({ imageCount: imagePaths.length }, "\uD83D\uDDBC️ Images extraites pour Vision LLM");
return imagePaths;
} catch (error) {
logger2.error({ error }, "❌ Erreur lors de l'extraction des images");
throw new Error(`❌ Échec de l'extraction d'images: ${error.message}`);
}
}
async function createTempDir() {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pdf-processor-"));
tempDirs.add(tempDir);
return tempDir;
}
async function cleanupTempDir(tempDir) {
try {
if (existsSync(tempDir)) {
await fs.rm(tempDir, { recursive: true, force: true });
tempDirs.delete(tempDir);
}
} catch (error) {
logger2.warn({ error, tempDir }, "⚠️ Could not clean up temp directory");
}
}
async function cleanupAllTempDirs() {
const cleanupPromises = Array.from(tempDirs).map(cleanupTempDir);
await Promise.allSettled(cleanupPromises);
}
var logger2, tempDirs;
var init_file_processor = __esm(() => {
init_logger();
logger2 = createModuleLogger("file-processor");
tempDirs = new Set;
process.on("beforeExit", async () => {
await cleanupAllTempDirs();
});
});
// src/api/server.ts
init_logger();
// src/core/vision/processor.ts
init_logger();
import { z as z5 } from "zod";
import path5 from "path";
// src/core/vision/image-optimization.ts
init_logger();
import fs2 from "fs/promises";
import path2 from "path";
// src/core/types.ts
var DEFAULT_MODELS = {
scaleway: "mistral-small-3.1-24b-instruct-2503",
ollama: "llava:13b",
mistral: "pixtral-12b-latest",
custom: ""
};
// src/core/vision/image-optimization.ts
var logger3 = createModuleLogger("image-optimization");
class ImageOptimizer {
async processDirect(filePath, options) {
const tempDir = await this.createTempDir();
try {
const { extractImagesFromPDF: extractImagesFromPDF2 } = await Promise.resolve().then(() => (init_file_processor(), exports_file_processor));
const rawImages = await extractImagesFromPDF2(filePath, tempDir, {
provider: options.provider,
dpi: options.dpi || 300,
extractImages: true
});
if (rawImages.length === 0) {
throw new Error("Aucune image extraite du PDF");
}
logger3.debug({ imageCount: rawImages.length }, "\uD83D\uDDBC️ Images extraites (mode direct)");
const optimizedImages = await this.optimizeDirectly(rawImages, options);
const totalOriginalSize = optimizedImages.reduce((sum, img) => sum + img.originalSizeBytes, 0);
const totalOptimizedSize = optimizedImages.reduce((sum, img) => sum + img.optimizedSizeBytes, 0);
const averageCompressionRatio = optimizedImages.reduce((sum, img) => sum + img.compressionRatio, 0) / optimizedImages.length;
return {
optimizedImages,
optimizationMetrics: {
originalSizeMB: totalOriginalSize / 1048576,
optimizedSizeMB: totalOptimizedSize / 1048576,
compressionRatio: averageCompressionRatio
}
};
} finally {
await this.cleanupTempDir(tempDir);
}
}
async optimizeDirectly(imagePaths, options) {
const sharp2 = (await import("sharp")).default;
const fs3 = await import("fs/promises");
const { existsSync: existsSync2 } = await import("fs");
const results = [];
for (const imagePath of imagePaths) {
if (!existsSync2(imagePath)) {
throw new Error(`Image not found: ${imagePath}`);
}
const originalStats = await fs3.stat(imagePath);
const originalSizeBytes = originalStats.size;
let pipeline = sharp2(imagePath);
const metadata = await pipeline.metadata();
const optimizations = [];
if (options.cropSize && options.cropSize > 0 && options.cropSize < 100) {
const cropPercent = options.cropSize / 100;
const cropWidth = Math.floor((metadata.width || 0) * cropPercent);
const cropHeight = Math.floor((metadata.height || 0) * cropPercent);
if (cropWidth > 100 && cropHeight > 100) {
const left = Math.floor(((metadata.width || 0) - cropWidth) / 2);
const top = Math.floor(((metadata.height || 0) - cropHeight) / 2);
pipeline = pipeline.extract({ left, top, width: cropWidth, height: cropHeight });
optimizations.push(`crop-${options.cropSize}%`);
}
}
const { maxPixels, maxDimension } = this.getMaxResolutionForProvider(options.provider, options.model);
const currentPixels = (metadata.width || 0) * (metadata.height || 0);
if (currentPixels > maxPixels || metadata.width && metadata.width > maxDimension || metadata.height && metadata.height > maxDimension) {
const aspectRatio = (metadata.width || 1) / (metadata.height || 1);
let newWidth = Math.sqrt(maxPixels * aspectRatio);
let newHeight = Math.sqrt(maxPixels / aspectRatio);
if (newWidth > maxDimension) {
newWidth = maxDimension;
newHeight = newWidth / aspectRatio;
}
if (newHeight > maxDimension) {
newHeight = maxDimension;
newWidth = newHeight * aspectRatio;
}
newWidth = Math.round(newWidth);
newHeight = Math.round(newHeight);
pipeline = pipeline.resize(newWidth, newHeight, {
kernel: sharp2.kernel.lanczos3,
withoutEnlargement: true
});
optimizations.push(`resize-${newWidth}x${newHeight}-pixels`);
}
if (options.enhanceContrast !== false) {
pipeline = pipeline.modulate({ brightness: 1.05, saturation: 1.1 }).sharpen({ sigma: 0.8, m1: 1, m2: 2, x1: 2, y2: 10, y3: 20 });
optimizations.push("contrast-enhanced");
}
const targetQuality = options.targetQuality || 95;
pipeline = pipeline.jpeg({
quality: targetQuality,
progressive: true,
mozjpeg: true
});
optimizations.push(`jpeg-q${targetQuality}`);
const optimizedBuffer = await pipeline.toBuffer();
const base64 = optimizedBuffer.toString("base64");
results.push({
base64,
optimizedSizeBytes: optimizedBuffer.length,
originalSizeBytes,
compressionRatio: optimizedBuffer.length / originalSizeBytes,
optimizations
});
}
logger3.debug({ imageCount: results.length }, "✅ Images optimisées (mode direct)");
return results;
}
async createTempDir() {
const tempDir = path2.join(process.env.EK_TMPDIR || "/tmp", `ai-vision-${Date.now()}`);
await fs2.mkdir(tempDir, { recursive: true });
return tempDir;
}
async cleanupTempDir(tempDir) {
try {
await fs2.rm(tempDir, { recursive: true, force: true });
} catch (error) {
logger3.warn({ error, tempDir }, "⚠️ Erreur nettoyage répertoire temporaire");
}
}
getMaxResolutionForProvider(provider, model) {
const modelName = model || DEFAULT_MODELS[provider] || "";
if (modelName.toLowerCase().includes("pixtral")) {
return {
maxPixels: 1048576,
maxDimension: 1024
};
} else if (modelName.toLowerCase().includes("mistral")) {
return {
maxPixels: 2371600,
maxDimension: 1540
};
}
return {
maxPixels: 4194304,
maxDimension: 2048
};
}
}
// src/core/vision/worker-manager.ts
init_logger();
import path4 from "path";
// src/core/workers/worker-pool-manager.ts
init_logger();
import path3 from "path";
import crypto from "crypto";
var logger4 = createModuleLogger("worker-pool-manager");
class WorkerPool {
workers = [];
availableWorkers = [];
pendingTasks = [];
activeTasks = new Map;
config;
constructor(config) {
this.config = config;
this.initializeWorkers();
}
initializeWorkers() {
logger4.info({ maxWorkers: this.config.maxWorkers, script: path3.basename(this.config.workerScript) }, "\uD83C\uDFED Initialisation pool");
for (let i = 0;i < this.config.maxWorkers; i++) {
const worker = new Worker(this.config.workerScript);
worker.onmessage = (event) => {
this.handleWorkerMessage(worker, event.data);
};
worker.onerror = (error) => {
this.handleWorkerError(worker, error);
};
this.workers.push(worker);
this.availableWorkers.push(worker);
}
}
async executeTask(data, timeout) {
return new Promise((resolve, reject) => {
const taskId = crypto.randomUUID();
const task = {
taskId,
data: { ...data, taskId },
resolve,
reject,
timeout: timeout || this.config.taskTimeout
};
this.pendingTasks.push(task);
this.processPendingTasks();
});
}
processPendingTasks() {
while (this.pendingTasks.length > 0 && this.availableWorkers.length > 0) {
const task = this.pendingTasks.shift();
const worker = this.availableWorkers.shift();
this.activeTasks.set(task.taskId, task);
setTimeout(() => {
if (this.activeTasks.has(task.taskId)) {
this.handleTaskTimeout(task);
}
}, task.timeout);
worker.postMessage(task.data);
}
}
handleWorkerMessage(worker, result) {
const taskId = result.taskId;
const task = this.activeTasks.get(taskId);
if (!task) {
logger4.warn({ taskId }, "⚠️ Réponse worker pour tâche inconnue");
return;
}
this.activeTasks.delete(taskId);
this.availableWorkers.push(worker);
if (result.success) {
task.resolve(result);
} else {
task.reject(new Error(result.error || "Worker task failed"));
}
this.processPendingTasks();
}
handleWorkerError(worker, error) {
logger4.error({ error }, "❌ Erreur worker");
for (const [taskId, task] of this.activeTasks.entries()) {
task.reject(new Error(`Worker error: ${error.message}`));
this.activeTasks.delete(taskId);
}
this.workers = this.workers.filter((w) => w !== worker);
this.availableWorkers = this.availableWorkers.filter((w) => w !== worker);
this.createReplacementWorker();
}
handleTaskTimeout(task) {
this.activeTasks.delete(task.taskId);
task.reject(new Error(`Task timeout after ${task.timeout}ms`));
}
createReplacementWorker() {
try {
const worker = new Worker(this.config.workerScript);
worker.onmessage = (event) => {
this.handleWorkerMessage(worker, event.data);
};
worker.onerror = (error) => {
this.handleWorkerError(worker, error);
};
this.workers.push(worker);
this.availableWorkers.push(worker);
logger4.info("\uD83D\uDD04 Worker de remplacement créé");
} catch (error) {
logger4.error({ error }, "❌ Impossible de créer un worker de remplacement");
}
}
getStats() {
return {
totalWorkers: this.workers.length,
availableWorkers: this.availableWorkers.length,
activeTasks: this.activeTasks.size,
pendingTasks: this.pendingTasks.length,
utilization: (this.workers.length - this.availableWorkers.length) / this.workers.length * 100
};
}
async shutdown() {
logger4.info("\uD83D\uDD3B Arrêt du pool de workers...");
for (const [taskId, task] of this.activeTasks.entries()) {
task.reject(new Error("Worker pool shutdown"));
}
this.activeTasks.clear();
this.pendingTasks.forEach((task) => {
task.reject(new Error("Worker pool shutdown"));
});
this.pendingTasks.length = 0;
await Promise.all(this.workers.map((worker) => worker.terminate()));
this.workers.length = 0;
this.availableWorkers.length = 0;
}
}
class WorkerPoolManager {
pools = new Map;
getPool(name, config) {
if (!this.pools.has(name)) {
if (!config) {
throw new Error(`Pool '${name}' n'existe pas et aucune configuration fournie`);
}
this.pools.set(name, new WorkerPool(config));
}
return this.pools.get(name);
}
getGlobalStats() {
const stats = new Map;
for (const [name, pool] of this.pools.entries()) {
stats.set(name, pool.getStats());
}
return Object.fromEntries(stats);
}
async shutdownAll() {
logger4.info("\uD83D\uDD3B Arrêt de tous les pools de workers...");
await Promise.all(Array.from(this.pools.values()).map((pool) => pool.shutdown()));
this.pools.clear();
}
}
var workerPoolManager = new WorkerPoolManager;
process.on("SIGINT", async () => {
await workerPoolManager.shutdownAll();
process.exit(0);
});
process.on("SIGTERM", async () => {
await workerPoolManager.shutdownAll();
process.exit(0);
});
// src/core/vision/worker-manager.ts
var __dirname = "/home/killian/Documents/dev/extract-kit/packages/pdf-processor/src/core/vision";
var logger5 = createModuleLogger("worker-manager");
class WorkerManager {
useWorkers;
constructor() {
this.useWorkers = process.env.EK_ENABLE_WORKERS === "true" || process.env.EK_ENABLE_WORKERS === "1";
if (this.useWorkers) {
this.initializeWorkerPools();
} else {
logger5.info("⚡ Mode direct activé (workers désactivés) - Set EK_ENABLE_WORKERS=true pour activer les workers");
}
}
isEnabled() {
return this.useWorkers;
}
initializeWorkerPools() {
workerPoolManager.getPool("pdf-extraction", {
maxWorkers: parseInt(process.env.EK_PDF_WORKERS || "2"),
workerScript: path4.join(__dirname, "../workers/pdf-extraction-worker.ts"),
taskTimeout: parseInt(process.env.EK_PDF_WORKER_TIMEOUT || "60000")
});
workerPoolManager.getPool("vision-optimization", {
maxWorkers: parseInt(process.env.EK_VISION_WORKERS || "3"),
workerScript: path4.join(__dirname, "../workers/vision-optimization-worker.ts"),
taskTimeout: parseInt(process.env.EK_VISION_WORKER_TIMEOUT || "30000")
});
const totalWorkers = parseInt(process.env.EK_PDF_WORKERS || "2") + parseInt(process.env.EK_VISION_WORKERS || "3");
logger5.info({ pdfWorkers: process.env.EK_PDF_WORKERS || "2", visionWorkers: process.env.EK_VISION_WORKERS || "3", totalWorkers }, "\uD83C\uDFED Workers activés");
}
async extractImages(filePath, options) {
const tempDir = await this.createTempDir();
const pdfPool = workerPoolManager.getPool("pdf-extraction");
logger5.debug({ file: path4.basename(filePath) }, "\uD83D\uDCC4 Worker PDF: Extraction");
const task = {
taskId: "",
pdfPath: filePath,
outputDir: tempDir,
dpi: options.dpi || 300
};
const result = await pdfPool.executeTask(task);
if (!result.success || !result.imagePaths) {
throw new Error(`PDF extraction failed: ${result.error}`);
}
logger5.info({ pageCount: result.pageCount, processingTime: result.processingTime }, "✅ Worker PDF: Pages extraites");
return {
imagePaths: result.imagePaths,
pageCount: result.pageCount || result.imagePaths.length
};
}
async optimizeImages(imagePaths, options) {
const visionPool = workerPoolManager.getPool("vision-optimization");
logger5.debug({ imageCount: imagePaths.length }, "\uD83C\uDFA8 Worker Vision: Optimisation images");
const task = {
taskId: "",
imagePaths,
options: {
provider: options.provider,
cropSize: options.cropSize,
enhanceContrast: options.enhanceContrast !== false,
preserveColor: true,
targetQuality: options.targetQuality || 95
}
};
const result = await visionPool.executeTask(task);
if (!result.success || !result.images) {
throw new Error(`Vision optimization failed: ${result.error}`);
}
logger5.info({
imageCount: result.images.length,
processingTime: result.processingTime,
originalSizeMB: (result.totalOriginalSize / 1024 / 1024).toFixed(1),
optimizedSizeMB: (result.totalOptimizedSize / 1024 / 1024).toFixed(1)
}, "✅ Worker Vision: Images optimisées");
return result;
}
async createTempDir() {
const fs3 = await import("fs/promises");
const tempDir = path4.join(process.env.EK_TMPDIR || "/tmp", `ai-vision-${Date.now()}`);
await fs3.mkdir(tempDir, { recursive: true });
return tempDir;
}
}
// src/core/vision/schema-selector.ts
init_logger();
// src/core/schemas/base.ts
import { z } from "zod";
var AddressSchema = z.object({
street: z.string().nullable().optional(),
city: z.string().nullable().optional(),
postal_code: z.string().nullable().optional(),
country: z.string().nullable().optional()
});
var ContactInfoSchema = z.object({
name: z.string().nullable().optional(),
company_name: z.string().nullable().optional(),
address: AddressSchema.nullable().optional(),
phone: z.string().nullable().optional(),
email: z.string().email().nullable().or(z.literal(null)).optional(),
website: z.string().nullable().optional(),
vat_number: z.string().nullable().optional(),
tax_id: z.string().nullable().optional()
});
var DocumentInfoSchema = z.object({
document_type: z.string().nullable().optional(),
language: z.string().nullable().optional(),
currency: z.string().nullable().optional(),
total_pages: z.number().int().nullable().optional()
});
var PaymentInfoSchema = z.object({
payment_terms: z.string().nullable().optional(),
payment_method: z.string().nullable().optional(),
payment_due_date: z.string().nullable().optional(),
bank_details: z.string().nullable().optional(),
iban: z.string().nullable().optional(),
swift_code: z.string().nullable().optional()
});
// src/core/schemas/invoice.ts
import { z as z2 } from "zod";
var InvoiceLineItemSchema = z2.object({
item_number: z2.string().nullable().optional(),
description: z2.string().nullable().optional(),
quantity: z2.number().nullable().optional(),
unit: z2.string().nullable().optional(),
unit_price: z2.number().nullable().optional(),
discount: z2.number().nullable().optional(),
tax_rate: z2.number().nullable().optional(),
tax_amount: z2.number().nullable().optional(),
line_total: z2.number().nullable().optional(),
quantite: z2.number().nullable().optional(),
prix_unitaire: z2.number().nullable().optional(),
montant_ht: z2.number().nullable().optional(),
montant_ttc: z2.number().nullable().optional(),
taux_tva: z2.number().nullable().optional(),
montant_tva: z2.number().nullable().optional(),
currency: z2.string().nullable().optional()
});
var FinancialTotalsSchema = z2.object({
subtotal: z2.number().nullable().optional(),
discount_total: z2.number().nullable().optional(),
tax_total: z2.number().nullable().optional(),
shipping_cost: z2.number().nullable().optional(),
total_amount: z2.number().nullable().optional(),
amount_paid: z2.number().nullable().optional(),
balance_due: z2.number().nullable().optional(),
total_ht: z2.number().nullable().optional(),
total_tva: z2.number().nullable().optional(),
total_ttc: z2.number().nullable().optional(),
montant_ht: z2.number().nullable().optional(),
montant_tva: z2.number().nullable().optional(),
montant_ttc: z2.number().nullable().optional(),
currency: z2.string().nullable().optional()
});
var InvoiceDetailsSchema = z2.object({
invoice_number: z2.string().nullable().optional(),
invoice_date: z2.string().nullable().optional(),
due_date: z2.string().nullable().optional(),
purchase_order: z2.string().nullable().optional(),
reference_number: z2.string().nullable().optional()
});
var ComprehensiveInvoiceSchema = z2.object({
document_info: DocumentInfoSchema.optional(),
invoice_details: InvoiceDetailsSchema.optional(),
seller_info: ContactInfoSchema.optional(),
buyer_info: ContactInfoSchema.optional(),
line_items: z2.array(InvoiceLineItemSchema).optional(),
financial_totals: FinancialTotalsSchema.optional(),
payment_info: PaymentInfoSchema.optional(),
pages: z2.array(z2.object({
page: z2.number(),
page_tables: z2.array(z2.object({
billed_services: z2.array(InvoiceLineItemSchema).optional(),
totals: FinancialTotalsSchema.optional(),
sections_detaillees: z2.record(z2.object({
items: z2.record(z2.object({
quantite: z2.number().nullable().optional(),
prix_unitaire: z2.number().nullable().optional(),
montant_ht: z2.number().nullable().optional(),
taux_tva: z2.number().nullable().optional(),
montant_tva: z2.number().nullable().optional(),
montant_ttc: z2.number().nullable().optional()
})),
sous_total: FinancialTotalsSchema.optional()
})).optional(),
total: FinancialTotalsSchema.optional(),
reference: z2.string().optional(),
exercice: z2.string().nullable().optional(),
montant_ttc: z2.number().nullable().optional(),
currency: z2.string().nullable().optional(),
raw_data: z2.record(z2.any()).optional()
}))
})).optional(),
extraction_metadata: z2.object({
confidence_score: z2.number().min(0).max(1).nullable(),
fields_found: z2.number().int().nullable(),
fields_empty: z2.number().int().nullable(),
processing_notes: z2.array(z2.string()).optional()
}).optional()
});
var BasicReceiptSchema = z2.object({
merchant_name: z2.string().nullable().optional(),
transaction_date: z2.string().nullable().optional(),
total_amount: z2.number().nullable().optional(),
payment_method: z2.string().nullable().optional(),
currency: z2.string().nullable().optional(),
items: z2.array(z2.object({
name: z2.string().nullable().optional(),
price: z2.number().nullable().optional()
})).optional()
});
// src/core/schemas/tables.ts
import { z as z3 } from "zod";
var TableRowSchema = z3.array(z3.union([z3.string(), z3.number(), z3.null()]));
var DetectedTableSchema = z3.object({
table_name: z3.string().nullable().optional(),
table_type: z3.string().nullable().optional(),
headers: z3.array(z3.string()).optional(),
rows: z3.array(TableRowSchema).optional(),
summary: z3.string().nullable().optional()
});
var TablesOnlySchema = z3.object({
detected_tables: z3.array(DetectedTableSchema).optional(),
extraction_metadata: z3.object({
tables_found: z3.number().int().nullable().optional(),
confidence_score: z3.number().min(0).max(1).nullable().optional()
}).optional()
});
// src/core/schemas/factory.ts
import { z as z4 } from "zod";
class SchemaFactory {
static createFromJSON(jsonSchema) {
try {
const parsed = JSON.parse(jsonSchema);
return this.convertJSONSchemaToZod(parsed);
} catch (error) {
throw new Error(`Invalid JSON schema: ${error.message}`);
}
}
static convertJSONSchemaToZod(jsonSchema) {
if (jsonSchema.type === "object" && jsonSchema.properties) {
const shape = {};
for (const [key, prop] of Object.entries(jsonSchema.properties)) {
shape[key] = this.convertPropertyToZod(prop);
}
return z4.object(shape);
}
return z4.any();
}
static convertPropertyToZod(prop) {
if (Array.isArray(prop.type)) {
if (prop.type.includes("null")) {
const nonNullType = prop.type.find((t) => t !== "null");
return this.getZodTypeForString(nonNullType).nullable();
}
}
if (prop.type === "array" && prop.items) {
const itemSchema = this.convertPropertyToZod(prop.items);
return z4.array(itemSchema);
}
if (prop.type === "object" && prop.properties) {
return this.convertJSONSchemaToZod(prop);
}
return this.getZodTypeForString(prop.type);
}
static getZodTypeForString(type) {
switch (type) {
case "string":
return z4.string();
case "number":
return z4.number();
case "integer":
return z4.number().int();
case "boolean":
return z4.boolean();
case "array":
return z4.array(z4.any());
case "object":
return z4.object({});
default:
return z4.any();
}
}
static getSchemaForDocumentType(documentType) {
switch (documentType.toLowerCase()) {
case "invoice":
return ComprehensiveInvoiceSchema;
case "receipt":
return BasicReceiptSchema;
case "tables":
return TablesOnlySchema;
case "basic":
case "simple":
return ComprehensiveInvoiceSchema;
default:
return ComprehensiveInvoiceSchema;
}
}
}
// src/core/vision/schema-selector.ts
var logger6 = createModuleLogger("schema-selector");
class SchemaSelector {
selectSchema(options) {
if (options.customSchema) {
logger6.debug("Utilisation schéma personnalisé");
return { schema: options.customSchema, schemaName: "custom" };
}
if (options.tablesOnly) {
logger6.debug("Mode tableaux uniquement activé");
return { schema: TablesOnlySchema, schemaName: "tables-only" };
}
if (options.documentType) {
logger6.debug({ documentType: options.documentType }, "Sélection par type de document");
const schema = SchemaFactory.getSchemaForDocumentType(options.documentType);
return { schema, schemaName: options.documentType };
}
if (options.query && options.query !== "*" && this.isValidJSON(options.query)) {
try {
logger6.debug("Conversion JSON Schema → Zod");
const schema = SchemaFactory.createFromJSON(options.query);
return { schema, schemaName: "custom-json" };
} catch (error) {
logger6.warn("⚠️ Échec conversion JSON → Zod, utilisation schéma complet");
}
}
logger6.debug("Utilisation schéma complet par défaut");
return { schema: ComprehensiveInvoiceSchema, schemaName: "comprehensive" };
}
isValidJSON(str) {
try {
JSON.parse(str);
return true;
} catch {
return false;
}
}
}
// src/core/vision/ai-generator.ts
init_logger();
import { generateObject } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
var logger7 = createModuleLogger("ai-generator");
class AIGenerator {
async generate(images, schema, options) {
const provider = options.provider || "scaleway";
const modelName = options.model || options.pdfProcessor?.providers?.[provider]?.model || DEFAULT_MODELS[provider];
const model = this.getModelInstance(provider, options.model, options.pdfProcessor);
const prompt = this.buildPromptForSchema(schema, options);
const modelToUse = options.model || DEFAULT_MODELS[provider];
logger7.debug({ provider, model: modelToUse }, "\uD83C\uDFAF Génération avec AI");
const imageMessages = this.formatImagesForProvider(images, provider);
logger7.debug({
provider,
model: modelName,
imageCount: images.length,
schemaKeys: Object.keys(schema.shape || {}),
promptLength: prompt.length,
systemPromptLength: this.getSystemPrompt(provider).length,
maxRetries: options.maxRetries || 2
}, "\uD83C\uDFAF Début génération AI avec tous les détails");
try {
const result = await generateObject({
model,
schema,
messages: [
{
role: "system",
content: this.getSystemPrompt(provider)
},
{
role: "user",
content: [
{ type: "text", text: prompt },
...imageMessages
]
}
],
maxRetries: options.maxRetries || 2
});
logger7.debug("✅ Données structurées générées et validées par Zod");
return { ...result, modelUsed: modelName };
} catch (error) {
console.log("❌ Erreur lors de la génération AI", error);
logger7.error({
error: error.message || error.toString(),
stack: error.stack,
name: error.name,
provider,
model: modelName,
cause: error.cause
}, "❌ Erreur génération AI");
throw error;
}
}
getModelInstance(provider, model, config) {
const providerConfig = config?.providers?.[provider];
const modelToUse = model || providerConfig?.model || DEFAULT_MODELS[provider];
logger7.debug({
provider,
model: modelToUse,
hasCustomConfig: !!providerConfig
}, "\uD83E\uDD16 Configuration modèle");
switch (provider) {
case "scaleway":
const apiKey = providerConfig?.apiKey || process.env.EK_AI_API_KEY;
const baseURL = providerConfig?.baseURL || process.env.EK_AI_BASE_URL || "https://api.scaleway.ai/v1";
if (!apiKey) {
throw new Error("Scaleway API key requis: fournissez-le via pdfProcessor.providers.scaleway.apiKey ou EK_AI_API_KEY");
}
const scalewayClient = createOpenAI({ apiKey, baseURL });
return scalewayClient.chat(modelToUse);
case "mistral":
const mistralApiKey = providerConfig?.apiKey || process.env.EK_MISTRAL_API_KEY;
const mistralBaseURL = providerConfig?.baseURL;
if (!mistralApiKey) {
throw new Error("Mistral API key requis: fournissez-le via pdfProcessor.providers.mistral.apiKey ou EK_MISTRAL_API_KEY");
}
const mistralConfig = { apiKey: mistralApiKey, baseURL: "https://api.mistral.ai/v1" };
if (mistralBaseURL) {
mistralConfig.baseURL = mistralBaseURL;
}
const mistralClient = createOpenAI(mistralConfig);
return mistralClient.chat(modelToUse);
case "ollama":
const ollamaConfig = {
baseURL: "http://localhost:11434/api"
};
if (providerConfig?.baseURL) {
ollamaConfig.baseURL = providerConfig.baseURL;
}
const ollama = createOpenAI(ollamaConfig);
return ollama.chat(modelToUse);
case "custom":
const customApiKey = providerConfig?.apiKey || process.env.CUSTOM_API_KEY;
const customBaseURL = providerConfig?.baseURL;
if (!customApiKey) {
throw new Error("Custom API key requis: fournissez-le via pdfProcessor.providers.custom.apiKey ou CUSTOM_API_KEY");
}
if (!customBaseURL) {
throw new Error("Custom baseURL requis: fournissez-le via pdfProcessor.providers.custom.baseURL");
}
const customClient = createOpenAI({ apiKey: customApiKey, baseURL: customBaseURL });
return customClient.chat(modelToUse);
default:
throw new Error(`Provider non supporté: ${provider}. Seuls 'scaleway', 'mistral', 'ollama' et 'custom' sont supportés.`);
}
}
buildPromptForSchema(schema, options) {
const basePrompt = `Extract structured data from this document following the provided schema exactly.`;
if (options.tablesOnly) {
return `${basePrompt}
TASK: Focus exclusively on extracting tables and tabular data.
- Identify all tables in the document
- Extract headers and all data rows completely
- Preserve data types (numbers as numbers)
- Use null for empty cells
- Include table context/summary if visible`;
}
if (options.documentType === "invoice") {
return `${basePrompt}
TASK: Extract comprehensive invoice information.
- Invoice details (number, date, amounts)
- Seller and buyer information
- Line items with quantities and prices
- Financial totals and tax information
- Payment terms and banking details
- Use null for missing fields - never guess`;
}
return `${basePrompt}
TASK: Perform comprehensive document data extraction.
- Extract all visible information systematically
- Follow the schema structure exactly
- Use appropriate data types (numbers as numbers, not strings)
- Use null for fields that are not visible or unclear
- Maintain high precision - only extract clearly visible data
`;
}
formatImagesForProvider(images, provider) {
return images.map((img) => ({
type: "image",
image: `data:image/jpeg;base64,${img.base64}`
}));
}
getSystemPrompt(provider) {
return `You are a professional document data extraction specialist. You excel at:
- Extracting structured data from invoices, receipts, and business documents
- Following schemas with absolute precision
- Using appropriate data types (numbers as numbers, not strings)
- Returning null for missing or unclear fields - never guess
- Maintaining high accuracy with complex document layouts
- Generate valid JSON only - no trailing commas, no empty objects with trailing commas
- If an object/array is empty, write it as {} or [] without trailing commas
Extract data exactly as requested in the schema.`;
}
}
// src/core/vision/processor.ts
var logger8 = createModuleLogger("vision-processor");
class AIVisionProcessor {
workerManager;
imageOptimizer;
schemaSelector;
aiGenerator;
constructor() {
this.workerManager = new WorkerManager;
this.imageOptimizer = new ImageOptimizer;
this.schemaSelector = new SchemaSelector;
this.aiGenerator = new AIGenerator;
}
async process(filePath, options) {
const startTime = Date.now();
const provider = options.provider || "scaleway";
logger8.info({ file: path5.basename(filePath), provider, model: options.model }, "\uD83E\uDD16 AI Vision démarrage");
try {
const { optimizedImages, optimizationMetrics, pageCount } = await this.processImages(filePath, options);
const { schema, schemaName } = this.schemaSelector.selectSchema(options);
const result = await this.aiGenerator.generate(optimizedImages, schema, options);
const processingTime = Date.now() - startTime;
logger8.info({ processingTime, schemaName }, "✅ AI Vision terminé");
return {
data: result.object,
metadata: {
pageCount,
processingTime,
provider,
model: result.modelUsed,
schemaUsed: schemaName,
optimizationMetrics
},
validation: {
success: true
}
};
} catch (error) {
logger8.error({
error: error.message || error.toString(),
stack: error.stack,
name: error.name,
cause: error.cause
}, "❌ Erreur AI Vision");
if (error instanceof z5.ZodError) {
return {
data: null,
metadata: {
pageCount: 0,
processingTime: Date.now() - startTime,
provider,
model: "unknown",
schemaUsed: "unknown",
optimizationMetrics: { originalSizeMB: 0, optimizedSizeMB: 0, compressionRatio: 1 }
},
validation: {
success: false,
errors: error
}
};
}
logger8.error({
error: error.message || error.toString(),
stack: error.stack,
name: error.name,
status: error.status,
statusText: error.statusText,
url: error.url,
response: error.response,
cause: error.cause
}, "❌ AI Vision processing failed - Détails complets");
throw new Error(`AI Vision processing failed: ${error.message}`);
}
}
async processImages(filePath, options) {
if (this.workerManager.isEnabled()) {
logger8.debug("\uD83C\uDFED Mode Workers activé");
const extractResult = await this.workerManager.extractImages(filePath, options);
const optimizationResult = await this.workerManager.optimizeImages(extractResult.imagePaths, options);
return {
optimizedImages: optimizationResult.images,
optimizationMetrics: {
originalSizeMB: (optimizationResult.totalOriginalSize || 0) / (1024 * 1024),
optimizedSizeMB: (optimizationResult.totalOptimizedSize || 0) / (1024 * 1024),
compressionRatio: optimizationResult.averageCompressionRatio || 1
},
pageCount: extractResult.pageCount
};
} else {
logger8.debug("⚡ Mode Direct activé");
const directResult = await this.imageOptimizer.processDirect(filePath, options);
return {
optimizedImages: directResult.optimizedImages,
optimizationMetrics: directResult.optimizationMetrics,
pageCount: directResult.optimizedImages.length
};
}
}
}
var aiVisionProcessor = new AIVisionProcessor;
// src/core/vision/index.ts
async function extractWithAI(filePath, schema, options = {}) {
const result = await aiVisionProcessor.process(filePath, {
provider: options.provider || "scaleway",
model: options.model,
customSchema: schema,
enhanceContrast: options.enhanceContrast !== false,
targetQuality: options.targetQuality || 95,
...options
});
if (!result.validation.success) {
throw new Error(`Validation failed: ${result.validation.errors?.message}`);
}
return result.data;
}
async function extractInvoice(filePath, options = {}) {
return extractWithAI(filePath, ComprehensiveInvoiceSchema, {
documentType: "invoice",
...options
});
}
async function extractTables(filePath, options = {}) {
return extractWithAI(filePath, TablesOnlySchema, {
tablesOnly: true,
...options
});
}
// src/api/handlers.ts
init_logger();
// src/api/validation.ts
import { z as z6 } from "zod";
var ExtractRequestSchema = z6.object({
provider: z6.enum(["scaleway", "ollama"]).optional().default("scaleway"),
model: z6.string().optional(),
query: z6.string().optional().default("*"),
cropSize: z6.number().min(10).max(100).optional(),
tablesOnly: z6.boolean().optional().default(false),
documentType: z6.enum(["invoice", "receipt", "basic", "custom"]).optional(),
enhanceContrast: z6.boolean().optional().default(true),
targetQuality: z6.number().min(70).max(100).optional().default(95),
debug: z6.boolean().optional().default(false)
});
function validateExtractRequest(body) {
try {
const validated = ExtractRequestSchema.parse(body);
return { valid: true, data: validated };
} catch (error) {
return { valid: false, error: error.message };
}
}
// src/api/utils.ts
init_logger();
import fs3 from "fs/promises";
import path6 from "path";
var logger9 = createModuleLogger("api-utils");
async function createTempFile(content, extension = "pdf") {
const tempFileName = path6.join("/tmp", `vision_${Date.now()}.${extension}`);
await Bun.write(tempFileName, content);
logger9.debug({ filePath: tempFileName }, "\uD83D\uDCC1 Fichier temporaire créé");
return {
filePath: tempFileName,
cleanup: async () => {
try {
await fs3.unlink(tempFileName);
logger9.debug({ filePath: tempFileName }, "\uD83E\uDDF9 Nettoyage fichier temporaire");
} catch (error) {
logger9.warn({ error, filePath: tempFileName }, "⚠️ Erreur nettoyage fichier temporaire");
}
}
};
}
function createCorsHeaders(origins = ["*"]) {
return {
"Access-Control-Allow-Origin": origins.join(", "),
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type"
};
}
// src/api/handlers.ts
var logger10 = createModuleLogger("api-handlers");
async function handleExtractRequest(req, corsHeaders) {
try {
logger10.info("\uD83C\uDFAF Requête Vision LLM reçue");
const contentType = req.headers.get("Content-Type") || "";
if (!contentType.includes("multipart/form-data")) {
return new Response(JSON.stringify({ success: false, error: "Content-Type doit être multipart/form-data" }), { status: 400, headers: { "Content-Type": "application/json", ...corsHeaders } });
}
const formData = await req.formData();
const pdfFile = formData.get("file");
if (!pdfFile || !(pdfFile instanceof File)) {
return new Response(JSON.stringify({ success: false, error: "Fichier PDF manquant ou invalide" }), { status: 400, headers: { "Content-Type": "application/json", ...corsHeaders } });
}
const requestBody = {
provider: formData.get("provider")?.toString(),
model: formData.get("model")?.toString(),
query: formData.get("query")?.toString(),
cropSize: formData.get("cropSize") ? parseInt(formData.get("cropSize").toString()) : undefined,
tablesOnly: formData.get("tablesOnly")?.toString() === "true",
documentType: formData.get("documentType")?.toString(),
enhanceContrast: formData.get("enhanceContrast")?.toString() !== "false",
targetQuality: formData.get("targetQuality") ? parseInt(formData.get("targetQuality").toString()) : undefined,
debug: formData.get("debug")?.toString() === "true"
};
const validation = validateExtractRequest(requestBody);
if (!validation.valid) {
return new Response(JSON.stringify({ success: false, error: `Paramètres invalides: ${validation.error}` }), { status: 400, headers: { "Content-Type": "application/json", ...corsHeaders } });
}
const options = validation.data;
logger10.info({ provider: options.provider, model: options.model || "default", query: options.query }, "\uD83D\uDD27 Configuration requête");
const { filePath, cleanup } = await createTempFile(await pdfFile.arrayBuffer());
try {
const result = await aiVisionProcessor.process(filePath, {
provider: options.provider,
model: options.model,
query: options.query,
cropSize: options.cropSize,
tablesOnly: options.tablesOnly,
documentType: options.documentType,
enhanceContrast: options.enhanceContrast,
targetQuality: options.targetQuality,
dpi: 300
});
logger10.info({ processingTime: result.metadata.processingTime, pages: result.metadata.pageCount }, "✅ Extraction réussie");
return new Response(JSON.stringify({ success: true, ...result }, null, options.debug ? 2 : 0), { headers: { "Content-Type": "application/json", ...corsHeaders } });
} finally {
await cleanup();
}
} catch (error) {
logger10.error({ error }, "❌ Erreur Vision API");
return new Response(JSON.stringify({ success: false, error: `Erreur serveur: ${error.message}` }), { status: 500, headers: { "Content-Type": "application/json", ...corsHeaders } });
}
}
async function handleInvoiceRequest(req, corsHeaders) {
try {
const formData = await req.formData();
const pdfFile = formData.get("file");
if (!pdfFile || !(pdfFile instanceof File)) {
return new Response(JSON.stringify({ success: false, error: "Fichier PDF requis" }), { status: 400, headers: { "Content-Type": "application/json", ...corsHeaders } });
}
const { filePath, cleanup } = await createTempFile(await pdfFile.arrayBuffer());
try {
const invoiceData = await extractInvoice(filePath, {
provider: formData.get("provider")?.toString() || "scaleway",
model: formData.get("model")?.toString(),
cropSize: formData.get("cropSize") ? parseInt(formData.get("cropSize").toString()) : undefined
});
return new Response(JSON.stringify({ success: true, invoice: invoiceData }), { headers: { "Content-Type": "application/json", ...corsHeaders } });
} finally {
await cleanup();
}
} catch (error) {
return new Response(JSON.stringify({ success: false, error: error.message }), { status: 500, headers: { "Content-Type": "application/json", ...corsHeaders } });
}
}
async function handleTablesRequest(req, corsHeaders) {
try {
const formData = await req.formData();
const pdfFile = formData.get("file");
if (!pdfFile || !(pdfFile instanceof File)) {
return new Response(JSON.stringify({ success: false, error: "Fichier PDF requis" }), { status: 400, headers: { "Content-Type": "application/json", ...corsHeaders } });
}
const { filePath, cleanup } = await createTempFile(await pdfFile.arrayBuffer());
try {
const tablesData = await extractTables(filePath, {
provider: formData.get("provider")?.toString() || "scaleway",
model: formData.get("model")?.toString()
});
return new Response(JSON.stringify({ success: true, tables: tablesData }), { headers: { "Content-Type": "application/json", ...corsHeaders } });
} finally {
await cleanup();
}
} catch (error) {
return new Response(JSON.stringify({ success: false, error: error.message }), { status: 500, headers: { "Content-Type": "application/json", ...corsHeaders } });
}
}
// src/api/server.ts
var serve = null;
try {
if (typeof Bun !== "undefined") {
const bunModule = await import("bun");
serve = bunModule.serve;
}
} catch (error) {
console.warn("Bun runtime not detected. Server functionality requires Bun runtime.");
}
var logger11 = createModuleLogger("api-server");
function createVisionAPI(config = {}) {
if (!serve) {
throw new Error("Server functionality requires Bun runtime. Install and run with Bun: https://bun.sh");
}
const serverConfig = {
port: config.port || process.env.PORT ? parseInt(process.env.PORT) : 3001,
cors: config.cors !== false,
corsOrigins: config.corsOrigins || ["*"]
};
const corsHeaders = serverConfig.cors ? createCorsHeaders(serverConfig.corsOrigins) : {};
const server = serve({
port: serverConfig.port,
async fetch(req) {
const url = new URL(req.url);
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
if (url.pathname === "/health") {
return new Response(JSON.stringify({
status: "ok",
service: "vision-llm-api",
features: ["sharp-optimization", "zod-validation", "ai-sdk"],
providers: ["scaleway", "ollama"]
}), {
headers: { "Content-Type": "application/json", ...corsHeaders }
});
}
if (url.pathname === "/api/v1/vision/extract" && req.method === "POST") {
return await handleExtractRequest(req, corsHeaders);
}
if (url.pathname === "/api/v1/vision/invoice" && req.method === "POST") {
return await handleInvoiceRequest(req, corsHeaders);
}
if (url.pathname === "/api/v1/vision/tables" && req.method === "POST") {
return await handleTablesRequest(req, corsHeaders);
}
return new Response(JSON.stringify({
success: false,
error: "Route non trouvée",
availableEndpoints: [
"POST /api/v1/vision/extract - Extraction configurable",
"POST /api/v1/vision/invoice - Extraction facture rapide",
"POST /api/v1/vision/tables - Extraction tableaux rapide",
"GET /health - Status"
]
}), {
status: 404,
headers: { "Content-Type": "application/json", ...corsHeaders }
});
}
});
logger11.info({ port: server.port }, "\uD83D\uDE80 Vision LLM API démarré");
logger11.info("Routes disponibles:");
logger11.info("- GET /health: Status du service");
logger11.info("- POST /api/v1/vision/extract: Extraction configurable avec Zod + AI SDK");
logger11.info("- POST /api/v1/vision/invoice: Extraction facture rapide");
logger11.info("- POST /api/v1/vision/tables: Extraction tableaux rapide");
logger11.info("✨ Optimisations: Sharp Vision LLM + Zod validation + AI SDK gene