← Code-Übersicht

TesseractInvoiceExtractor.php

Pfad: src/Infrastructure/Rechnung/TesseractInvoiceExtractor.php

Ext: php

Größe: 3487 Bytes

Geändert: 2026-07-16T14:11:28+02:00

Frühere Version vom 2026-07-16T14:11:28+02:00 · zur aktuellen Fassung

<?php
declare(strict_types=1);

namespace Demo\Infrastructure\Rechnung;

use Demo\Domain\Rechnung\Amount;
use Demo\Domain\Rechnung\ExtractedInvoiceData;
use Demo\Domain\Rechnung\TextExtractionPort;

/**
 * OCR-Fallback mit Tesseract.
 */
final class TesseractInvoiceExtractor implements TextExtractionPort
{
    /**
     * @param string $binary Pfad zu tesseract-Binary.
     */
    public function __construct(private string $binary = 'tesseract')
    {
    }

    /**
     * @inheritDoc
     */
    public function extract(string $pdfPath): ExtractedInvoiceData
    {
        if (!is_file($pdfPath)) {
            throw new \RuntimeException('tesseract: Datei nicht gefunden');
        }

        $base = $pdfPath . '_ocr';
        $command = sprintf(
            '%s %s %s txt 2>/dev/null',
            escapeshellcmd($this->binary),
            escapeshellarg($pdfPath),
            escapeshellarg($base)
        );
        exec($command, $output, $exitCode);

        $textFile = $base . '.txt';
        if (!is_file($textFile) || $exitCode !== 0) {
            throw new \RuntimeException('tesseract: OCR fehlgeschlagen');
        }

        $text = (string) file_get_contents($textFile);
        @unlink($textFile);

        return $this->parseText($text);
    }

    /**
     * @param string $text OCR-Rohtext.
     * @return ExtractedInvoiceData Parsed.
     */
    private function parseText(string $text): ExtractedInvoiceData
    {
        $normalized = preg_replace('/\s+/', ' ', $text);

        $vendorName = $this->extractAddressLine($text);
        $invoiceNumber = $this->extractPattern($normalized, [
            '/Rechnungsnummer\s*[:#]?\s*([A-Za-z0-9\-\/_]+)/i',
            '/Invoice\s*No\.?\s*[:#]?\s*([A-Za-z0-9\-\/_]+)/i',
        ]) ?? '';

        $invoiceDate = $this->extractPattern($normalized, [
            '/(\d{4}-\d{2}-\d{2})/',
            '/(\d{2}\.\d{2}\.\d{4})/',
        ]) ?? '';

        $netto = (float) ($this->extractPattern($normalized, ['/([0-9]+[\.,][0-9]{2})\s+Netto/i']) ?? 0.0);
        $steuer = (float) ($this->extractPattern($normalized, ['/([0-9]+[\.,][0-9]{2})\s+Steuer/i']) ?? 0.0);
        $brutto = (float) ($this->extractPattern($normalized, ['/([0-9]+[\.,][0-9]{2})\s+Brutto/i']) ?? 0.0);

        $amount = new Amount($netto, $steuer, $brutto, 'EUR');

        return new ExtractedInvoiceData(
            vendorName: $vendorName,
            vendorAddressSignature: $vendorName,
            invoiceNumber: $invoiceNumber,
            invoiceDate: $invoiceDate,
            amount: $amount,
            extractionConfidence: 0.4
        );
    }

    /**
     * @param string $text Text.
     * @param array  $patterns Patterns.
     * @return string|null Match.
     */
    private function extractPattern(string $text, array $patterns): ?string
    {
        foreach ($patterns as $pattern) {
            if (preg_match($pattern, $text, $matches) === 1) {
                return trim($matches[1] ?? '');
            }
        }

        return null;
    }

    /**
     * @param string $text Text.
     * @return string Erster Kandidat.
     */
    private function extractAddressLine(string $text): string
    {
        $parts = preg_split('/\R/', $text);
        foreach ($parts as $line) {
            $candidate = trim((string) $line);
            if (strlen($candidate) > 3 && preg_match('/[A-Za-zäöüÄÖÜß]/', $candidate)) {
                return $candidate;
            }
        }

        return '';
    }
}