netgescon-day0/app/Services/Documenti/PdfTextExtractionService.php

140 lines
4.6 KiB
PHP

<?php
namespace App\Services\Documenti;
use App\Models\Documento;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Smalot\PdfParser\Parser;
class PdfTextExtractionService
{
/**
* Estrae il testo da un PDF e lo salva su `documenti.contenuto_ocr` (se presente).
*
* @return array{status: 'ok'|'skipped'|'error', message?: string, chars?: int, engine?: string}
*/
public function extractAndStore(Documento $documento, bool $force = false): array
{
$relativePath = $this->resolvePdfPath($documento);
if ($relativePath === null) {
return ['status' => 'skipped', 'message' => 'PDF non disponibile'];
}
if (! Storage::disk('local')->exists($relativePath)) {
return ['status' => 'error', 'message' => 'File PDF non trovato su storage'];
}
if (! $force) {
$existing = null;
if (Schema::hasColumn('documenti', 'contenuto_ocr')) {
$existing = $documento->contenuto_ocr;
} elseif (Schema::hasColumn('documenti', 'testo_estratto_ocr')) {
$existing = $documento->testo_estratto_ocr;
}
if (is_string($existing) && trim($existing) !== '') {
return ['status' => 'skipped', 'message' => 'Testo già presente'];
}
}
$absolutePath = Storage::disk('local')->path($relativePath);
try {
[$text, $engine] = $this->extractText($absolutePath);
} catch (\Throwable $e) {
return ['status' => 'error', 'message' => $e->getMessage()];
}
$text = $this->normalizeText($text);
$chars = mb_strlen($text);
if ($chars <= 0) {
return ['status' => 'error', 'message' => 'Testo PDF vuoto (forse è una scansione)'];
}
$update = [];
if (Schema::hasColumn('documenti', 'contenuto_ocr')) {
$update['contenuto_ocr'] = $text;
}
if (Schema::hasColumn('documenti', 'testo_estratto_ocr')) {
$update['testo_estratto_ocr'] = mb_substr($text, 0, 20000);
}
if (empty($update)) {
return ['status' => 'error', 'message' => 'Schema documenti senza colonne OCR/testo'];
}
$documento->forceFill($update)->save();
return ['status' => 'ok', 'chars' => $chars, 'engine' => $engine];
}
private function resolvePdfPath(Documento $documento): ?string
{
$candidate = null;
if (isset($documento->percorso_file) && is_string($documento->percorso_file) && $documento->percorso_file !== '') {
$candidate = $documento->percorso_file;
} elseif (isset($documento->path_file) && is_string($documento->path_file) && $documento->path_file !== '') {
$candidate = $documento->path_file;
}
if (! is_string($candidate) || trim($candidate) === '') {
return null;
}
$candidate = trim($candidate);
$lower = strtolower($candidate);
if (! str_ends_with($lower, '.pdf')) {
return null;
}
return $candidate;
}
/**
* @return array{0:string,1:string} [text, engine]
*/
private function extractText(string $absolutePath): array
{
// Prefer pdftotext if available: è più robusto su tanti PDF “reali”.
$pdftotext = $this->findBinary('pdftotext');
if ($pdftotext !== null) {
$cmd = escapeshellcmd($pdftotext) . ' -layout -nopgbrk ' . escapeshellarg($absolutePath) . ' -';
$output = shell_exec($cmd);
$output = is_string($output) ? $output : '';
if (trim($output) !== '') {
return [$output, 'pdftotext'];
}
}
// Fallback PHP parser (no system deps).
$parser = new Parser();
$pdf = $parser->parseFile($absolutePath);
return [$pdf->getText(), 'smalot/pdfparser'];
}
private function findBinary(string $name): ?string
{
$path = shell_exec('command -v ' . escapeshellarg($name) . ' 2>/dev/null');
$path = is_string($path) ? trim($path) : '';
return $path !== '' ? $path : null;
}
private function normalizeText(string $text): string
{
// Normalize line endings and strip null bytes.
$text = str_replace("\r\n", "\n", $text);
$text = str_replace("\r", "\n", $text);
$text = str_replace("\0", '', $text);
// Best-effort encoding normalization.
if (! mb_check_encoding($text, 'UTF-8')) {
$text = mb_convert_encoding($text, 'UTF-8');
}
return trim($text);
}
}