242 lines
7.3 KiB
PHP
242 lines
7.3 KiB
PHP
<?php
|
|
/*
|
|
* pdf_extract_lib.php
|
|
* ------------------------------------------------------------------
|
|
* Cuore dell'estrazione PDF. Dato un file PDF e i riquadri (pdf_regions)
|
|
* salvati per ogni campo, usa `pdftotext -bbox -enc UTF-8` per ottenere
|
|
* le parole con le loro coordinate, e per ogni riquadro "value" raccoglie
|
|
* le parole il cui centro cade dentro il rettangolo.
|
|
*
|
|
* Le coordinate di pdftotext -bbox sono in punti PDF, origine in alto a
|
|
* sinistra: lo stesso sistema in cui PDF.js ha salvato i riquadri a scala
|
|
* 1.0. Quindi il match è diretto, senza conversioni.
|
|
*
|
|
* Funzioni pubbliche:
|
|
* - pdftotext_binary(): trova il percorso del binario su Win/Linux
|
|
* - pdf_extract_words($pdfPath): array di pagine con parole+coordinate
|
|
* - pdf_extract_values($pdfPath, $mappings): mapping_id => testo estratto
|
|
* ------------------------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* Trova il binario pdftotext in base al sistema operativo.
|
|
* Su Windows cerca prima il percorso noto, poi il PATH.
|
|
* Su Linux usa /usr/bin/pdftotext, poi il PATH.
|
|
*/
|
|
function pdftotext_binary(): string
|
|
{
|
|
$isWindows = (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN');
|
|
|
|
if ($isWindows) {
|
|
// Percorso noto sull'ambiente locale
|
|
$known = 'C:\\poppler\\poppler-26.02.0\\Library\\bin\\pdftotext.exe';
|
|
if (is_file($known)) {
|
|
return $known;
|
|
}
|
|
// Fallback: confida nel PATH
|
|
return 'pdftotext.exe';
|
|
}
|
|
|
|
// Linux / VPS
|
|
if (is_file('/usr/bin/pdftotext')) {
|
|
return '/usr/bin/pdftotext';
|
|
}
|
|
return 'pdftotext';
|
|
}
|
|
|
|
/**
|
|
* Lancia pdftotext -bbox e restituisce l'XHTML grezzo.
|
|
* @throws RuntimeException se il comando fallisce.
|
|
*/
|
|
function pdf_run_bbox(string $pdfPath): string
|
|
{
|
|
if (!is_file($pdfPath)) {
|
|
throw new RuntimeException("PDF non trovato: {$pdfPath}");
|
|
}
|
|
|
|
$bin = pdftotext_binary();
|
|
|
|
// -bbox: coordinate parole; -enc UTF-8: fix simboli (€, accenti); - : stdout
|
|
$cmd = escapeshellarg($bin)
|
|
. ' -bbox -enc UTF-8 '
|
|
. escapeshellarg($pdfPath)
|
|
. ' -';
|
|
|
|
$descriptors = [
|
|
1 => ['pipe', 'w'], // stdout
|
|
2 => ['pipe', 'w'], // stderr
|
|
];
|
|
|
|
$proc = proc_open($cmd, $descriptors, $pipes);
|
|
if (!is_resource($proc)) {
|
|
throw new RuntimeException("Impossibile avviare pdftotext.");
|
|
}
|
|
|
|
$stdout = stream_get_contents($pipes[1]);
|
|
$stderr = stream_get_contents($pipes[2]);
|
|
fclose($pipes[1]);
|
|
fclose($pipes[2]);
|
|
$exit = proc_close($proc);
|
|
|
|
if ($exit !== 0) {
|
|
throw new RuntimeException("pdftotext ha restituito codice {$exit}: {$stderr}");
|
|
}
|
|
|
|
return $stdout;
|
|
}
|
|
|
|
/**
|
|
* Parsa l'XHTML di pdftotext -bbox in una struttura:
|
|
* [
|
|
* 1 => [ 'width'=>612, 'height'=>792, 'words'=>[ ['t'=>'Invoice','xMin'=>..,'yMin'=>..,'xMax'=>..,'yMax'=>..], ... ] ],
|
|
* 2 => [ ... ],
|
|
* ]
|
|
* Chiave = numero pagina (1-based).
|
|
*/
|
|
function pdf_parse_bbox(string $xhtml): array
|
|
{
|
|
$pages = [];
|
|
|
|
// Isola ogni blocco <page ...> ... </page>
|
|
if (!preg_match_all('#<page\b[^>]*>(.*?)</page>#si', $xhtml, $pageMatches, PREG_OFFSET_CAPTURE)) {
|
|
return $pages;
|
|
}
|
|
|
|
// Recupera anche gli attributi width/height di ogni page
|
|
preg_match_all('#<page\s+width="([0-9.]+)"\s+height="([0-9.]+)"#i', $xhtml, $dimMatches, PREG_SET_ORDER);
|
|
|
|
$pageNum = 0;
|
|
foreach ($pageMatches[1] as $idx => $inner) {
|
|
$pageNum++;
|
|
$body = $inner[0];
|
|
|
|
$width = isset($dimMatches[$idx][1]) ? (float)$dimMatches[$idx][1] : 0.0;
|
|
$height = isset($dimMatches[$idx][2]) ? (float)$dimMatches[$idx][2] : 0.0;
|
|
|
|
$words = [];
|
|
if (preg_match_all(
|
|
'#<word\s+xMin="([0-9.\-]+)"\s+yMin="([0-9.\-]+)"\s+xMax="([0-9.\-]+)"\s+yMax="([0-9.\-]+)"\s*>(.*?)</word>#si',
|
|
$body,
|
|
$wm,
|
|
PREG_SET_ORDER
|
|
)) {
|
|
foreach ($wm as $w) {
|
|
$text = html_entity_decode($w[5], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
$words[] = [
|
|
't' => $text,
|
|
'xMin' => (float)$w[1],
|
|
'yMin' => (float)$w[2],
|
|
'xMax' => (float)$w[3],
|
|
'yMax' => (float)$w[4],
|
|
];
|
|
}
|
|
}
|
|
|
|
$pages[$pageNum] = [
|
|
'width' => $width,
|
|
'height' => $height,
|
|
'words' => $words,
|
|
];
|
|
}
|
|
|
|
return $pages;
|
|
}
|
|
|
|
/**
|
|
* Estrae tutte le parole con coordinate da un PDF.
|
|
* Ritorna la struttura di pdf_parse_bbox().
|
|
*/
|
|
function pdf_extract_words(string $pdfPath): array
|
|
{
|
|
$xhtml = pdf_run_bbox($pdfPath);
|
|
return pdf_parse_bbox($xhtml);
|
|
}
|
|
|
|
/**
|
|
* Dato un rettangolo {x,y,w,h} e la lista parole di una pagina, restituisce
|
|
* il testo delle parole il cui CENTRO cade dentro il rettangolo, ordinate
|
|
* per riga (yMin) e poi per colonna (xMin), unite da spazio.
|
|
*
|
|
* $tolerance espande il rettangolo di N punti su ogni lato (default 2),
|
|
* per non perdere parole tracciate al pelo del bordo.
|
|
*/
|
|
function pdf_text_in_rect(array $rect, array $words, float $tolerance = 2.0): string
|
|
{
|
|
$x1 = $rect['x'] - $tolerance;
|
|
$y1 = $rect['y'] - $tolerance;
|
|
$x2 = $rect['x'] + $rect['w'] + $tolerance;
|
|
$y2 = $rect['y'] + $rect['h'] + $tolerance;
|
|
|
|
$hits = [];
|
|
foreach ($words as $w) {
|
|
$cx = ($w['xMin'] + $w['xMax']) / 2.0;
|
|
$cy = ($w['yMin'] + $w['yMax']) / 2.0;
|
|
|
|
if ($cx >= $x1 && $cx <= $x2 && $cy >= $y1 && $cy <= $y2) {
|
|
$hits[] = $w;
|
|
}
|
|
}
|
|
|
|
if (empty($hits)) {
|
|
return '';
|
|
}
|
|
|
|
// Ordina per riga (yMin arrotondato a gruppi di ~3pt) poi per xMin
|
|
usort($hits, function ($a, $b) {
|
|
$ra = round($a['yMin'] / 3.0);
|
|
$rb = round($b['yMin'] / 3.0);
|
|
if ($ra !== $rb) {
|
|
return $ra <=> $rb;
|
|
}
|
|
return $a['xMin'] <=> $b['xMin'];
|
|
});
|
|
|
|
$parts = array_map(fn($w) => $w['t'], $hits);
|
|
$text = implode(' ', $parts);
|
|
|
|
// Normalizza spazi multipli
|
|
$text = preg_replace('/\s+/u', ' ', $text);
|
|
return trim($text);
|
|
}
|
|
|
|
/**
|
|
* Estrae i valori per ogni mapping che ha pdf_regions.
|
|
*
|
|
* @param string $pdfPath percorso del PDF da leggere
|
|
* @param array $mappings righe di template_mapping (devono contenere: id, pdf_regions)
|
|
* @return array [ mapping_id => testo_estratto ] (solo per i mapping con regione value)
|
|
*
|
|
* Nota: usa la regione "value" per estrarre il dato. La regione "label" al
|
|
* momento non è usata per l'estrazione (serve per la strategia futura di
|
|
* ricerca per etichetta, es. PDF con layout variabile).
|
|
*/
|
|
function pdf_extract_values(string $pdfPath, array $mappings): array
|
|
{
|
|
$pages = pdf_extract_words($pdfPath);
|
|
$result = [];
|
|
|
|
foreach ($mappings as $m) {
|
|
if (empty($m['pdf_regions'])) {
|
|
continue;
|
|
}
|
|
|
|
$regions = json_decode($m['pdf_regions'], true);
|
|
if (!is_array($regions) || empty($regions['value']) || empty($regions['page'])) {
|
|
continue;
|
|
}
|
|
|
|
$page = (int)$regions['page'];
|
|
if (!isset($pages[$page])) {
|
|
// pagina inesistente in questo PDF: valore vuoto
|
|
$result[(int)$m['id']] = '';
|
|
continue;
|
|
}
|
|
|
|
$value = $pdf_value_rect = $regions['value'];
|
|
$text = pdf_text_in_rect($value, $pages[$page]['words']);
|
|
$result[(int)$m['id']] = $text;
|
|
}
|
|
|
|
return $result;
|
|
}
|