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 ... if (!preg_match_all('#]*>(.*?)#si', $xhtml, $pageMatches, PREG_OFFSET_CAPTURE)) { return $pages; } // Recupera anche gli attributi width/height di ogni page preg_match_all('# $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( '#(.*?)#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; }