diff --git a/src/Controllers/BelegController.php b/src/Controllers/BelegController.php index 6325251..abaf4c4 100644 --- a/src/Controllers/BelegController.php +++ b/src/Controllers/BelegController.php @@ -10,6 +10,7 @@ use Steuerkram\Models\Account; use Steuerkram\Models\Supplier; use Steuerkram\Config; use Steuerkram\View; +use Steuerkram\Services\ImageCropService; use Steuerkram\Services\KontierungsvorschlagService; class BelegController @@ -93,7 +94,6 @@ class BelegController $file = $_FILES['beleg_file']; $mime = mime_content_type($file['tmp_name']); $ext = self::ALLOWED_MIME[strtolower($mime)]; - $hash = hash_file('sha256', $file['tmp_name']); $belegNr = Beleg::generateBelegNr(); $year = !empty($_POST['beleg_datum']) ? date('Y', strtotime($_POST['beleg_datum'])) @@ -111,6 +111,14 @@ class BelegController move_uploaded_file($file['tmp_name'], $absPath); + // Weißen Rand bei JPEG/PNG automatisch wegschneiden + if (in_array($mime, ['image/jpeg', 'image/png'], true)) { + (new ImageCropService())->cropWhitespace($absPath); + } + + // Hash nach potenziellem Crop berechnen (reflektiert den tatsächlich gespeicherten Inhalt) + $hash = hash_file('sha256', $absPath); + $id = Beleg::create([ 'beleg_nr' => $belegNr, 'filename' => $filename, diff --git a/src/Services/ImageCropService.php b/src/Services/ImageCropService.php new file mode 100644 index 0000000..239eeb7 --- /dev/null +++ b/src/Services/ImageCropService.php @@ -0,0 +1,147 @@ + imagecreatefromjpeg($path), + 'image/png' => imagecreatefrompng($path), + default => null, + }; + + if (!$img instanceof \GdImage) { + return false; + } + + $w = imagesx($img); + $h = imagesy($img); + + $box = $this->findBoundingBox($img, $w, $h); + + if ($box === null) { + imagedestroy($img); + return false; // Bild ist komplett weiß + } + + $cropX = max(0, $box['left'] - self::PADDING); + $cropY = max(0, $box['top'] - self::PADDING); + $cropW = min($w, $box['right'] + self::PADDING + 1) - $cropX; + $cropH = min($h, $box['bottom'] + self::PADDING + 1) - $cropY; + + // Zuschnitt nur durchführen wenn nennenswert Rand entfernt wird + if ($cropW >= $w * (1 - self::MIN_CROP_GAIN) && $cropH >= $h * (1 - self::MIN_CROP_GAIN)) { + imagedestroy($img); + return false; + } + + $cropped = imagecrop($img, ['x' => $cropX, 'y' => $cropY, 'width' => $cropW, 'height' => $cropH]); + imagedestroy($img); + + if (!$cropped instanceof \GdImage) { + return false; + } + + $saved = match ($mime) { + 'image/jpeg' => imagejpeg($cropped, $path, 92), + 'image/png' => imagepng($cropped, $path), + }; + + imagedestroy($cropped); + return $saved; + } + + /** + * Findet die kleinste Bounding-Box um alle nicht-weißen Pixel per Edge-Scanning. + * Scannt von jeder Kante einwärts und stoppt beim ersten nicht-weißen Pixel – + * deutlich schneller als ein vollständiger Pixel-Scan bei großen Bildern. + * Gibt null zurück wenn das gesamte Bild weiß ist. + * + * @return array{top:int,bottom:int,left:int,right:int}|null + */ + private function findBoundingBox(\GdImage $img, int $w, int $h): ?array + { + // Obere Kante: erste Zeile mit nicht-weißem Pixel + $top = null; + for ($y = 0; $y < $h && $top === null; $y++) { + for ($x = 0; $x < $w; $x++) { + if (!$this->isWhite(imagecolorat($img, $x, $y))) { + $top = $y; + break; + } + } + } + if ($top === null) { + return null; // komplett weiß + } + + // Untere Kante: letzte Zeile mit nicht-weißem Pixel + $bottom = $top; + for ($y = $h - 1; $y > $top; $y--) { + $found = false; + for ($x = 0; $x < $w; $x++) { + if (!$this->isWhite(imagecolorat($img, $x, $y))) { + $bottom = $y; + $found = true; + break; + } + } + if ($found) break; + } + + // Linke Kante: erste Spalte mit nicht-weißem Pixel (nur zwischen top und bottom) + $left = 0; + for ($x = 0; $x < $w; $x++) { + $found = false; + for ($y = $top; $y <= $bottom; $y++) { + if (!$this->isWhite(imagecolorat($img, $x, $y))) { + $left = $x; + $found = true; + break; + } + } + if ($found) break; + } + + // Rechte Kante: letzte Spalte mit nicht-weißem Pixel (nur zwischen top und bottom) + $right = $w - 1; + for ($x = $w - 1; $x > $left; $x--) { + $found = false; + for ($y = $top; $y <= $bottom; $y++) { + if (!$this->isWhite(imagecolorat($img, $x, $y))) { + $right = $x; + $found = true; + break; + } + } + if ($found) break; + } + + return ['top' => $top, 'bottom' => $bottom, 'left' => $left, 'right' => $right]; + } + + private function isWhite(int $color): bool + { + $r = ($color >> 16) & 0xFF; + $g = ($color >> 8) & 0xFF; + $b = $color & 0xFF; + + return $r >= (255 - self::FUZZ) + && $g >= (255 - self::FUZZ) + && $b >= (255 - self::FUZZ); + } +} diff --git a/tests/19_ImageCropTest.php b/tests/19_ImageCropTest.php new file mode 100644 index 0000000..85337c6 --- /dev/null +++ b/tests/19_ImageCropTest.php @@ -0,0 +1,154 @@ +testCropJpeg(); + $this->testCropPng(); + $this->testNoCropWhenBorderTooSmall(); + $this->testAllWhiteReturnsNoChange(); + $this->testUnsupportedMimeReturnsFalse(); + } + + // ------------------------------------------------------------------------- + + private function testCropJpeg(): void + { + // 300×200 weißes Bild, rotes Rechteck 50×50 px bei (100, 75) + $img = imagecreatetruecolor(300, 200); + $white = imagecolorallocate($img, 255, 255, 255); + $red = imagecolorallocate($img, 200, 50, 50); + imagefill($img, 0, 0, $white); + imagefilledrectangle($img, 100, 75, 149, 124, $red); + + $path = $this->saveTmp($img, 'jpeg'); + imagedestroy($img); + + $service = new \Steuerkram\Services\ImageCropService(); + $result = $service->cropWhitespace($path); + + $this->assert($result === true, 'JPEG: cropWhitespace() gibt true zurück'); + + [$newW, $newH] = getimagesize($path); + + // Erwartete Maße: Rechteck (100,75)–(149,124) + PADDING 25 ringsum → 100×100. + // JPEG-Verlust-Komprimierung kann Farbe ±2px in Nachbar-DCT-Blöcke bluten lassen, + // daher Toleranz von 2px je Seite. + $this->assert($newW <= 104 && $newW >= 96, "JPEG: Breite nach Crop im Bereich 96–104 px (erhalten: {$newW})"); + $this->assert($newH <= 104 && $newH >= 96, "JPEG: Höhe nach Crop im Bereich 96–104 px (erhalten: {$newH})"); + } + + private function testCropPng(): void + { + // 400×300 weißes PNG, blaues Rechteck 80×60 px bei (160, 120) + $img = imagecreatetruecolor(400, 300); + $white = imagecolorallocate($img, 255, 255, 255); + $blue = imagecolorallocate($img, 50, 100, 200); + imagefill($img, 0, 0, $white); + imagefilledrectangle($img, 160, 120, 239, 179, $blue); + + $path = $this->saveTmp($img, 'png'); + imagedestroy($img); + + $service = new \Steuerkram\Services\ImageCropService(); + $result = $service->cropWhitespace($path); + + $this->assert($result === true, 'PNG: cropWhitespace() gibt true zurück'); + + [$newW, $newH] = getimagesize($path); + + // cropX=135, cropY=95, cropW=130, cropH=110 + $this->assertEquals(130, $newW, 'PNG: Breite nach Crop korrekt (80px + 2×25 Padding)'); + $this->assertEquals(110, $newH, 'PNG: Höhe nach Crop korrekt (60px + 2×25 Padding)'); + } + + private function testNoCropWhenBorderTooSmall(): void + { + // Inhalt füllt fast das gesamte Bild – weniger als 3 % Rand + $img = imagecreatetruecolor(200, 200); + $white = imagecolorallocate($img, 255, 255, 255); + $red = imagecolorallocate($img, 200, 50, 50); + imagefill($img, 0, 0, $white); + // Inhalt lässt nur 1px Rand ringsum → Einsparung < 3 % + imagefilledrectangle($img, 1, 1, 198, 198, $red); + + $path = $this->saveTmp($img, 'jpeg'); + imagedestroy($img); + + $originalSize = filesize($path); + + $service = new \Steuerkram\Services\ImageCropService(); + $result = $service->cropWhitespace($path); + + $this->assert($result === false, 'Kein Crop wenn Randersparnis < 3 %'); + [$newW, $newH] = getimagesize($path); + $this->assertEquals(200, $newW, 'Bildbreite bleibt unverändert bei minimalem Rand'); + } + + private function testAllWhiteReturnsNoChange(): void + { + $img = imagecreatetruecolor(100, 100); + $white = imagecolorallocate($img, 255, 255, 255); + imagefill($img, 0, 0, $white); + + $path = $this->saveTmp($img, 'jpeg'); + imagedestroy($img); + + $service = new \Steuerkram\Services\ImageCropService(); + $result = $service->cropWhitespace($path); + + $this->assert($result === false, 'Komplett weißes Bild: kein Crop, gibt false zurück'); + } + + private function testUnsupportedMimeReturnsFalse(): void + { + // Erstellt eine Dummy-Textdatei – MIME-Typ wird nicht als JPEG/PNG erkannt + $path = sys_get_temp_dir() . '/crop_test_' . uniqid() . '.txt'; + file_put_contents($path, 'kein Bild'); + $this->tmpFiles[] = $path; + + $service = new \Steuerkram\Services\ImageCropService(); + $result = $service->cropWhitespace($path); + + $this->assert($result === false, 'Nicht-Bild-Datei: cropWhitespace() gibt false zurück'); + } + + // ------------------------------------------------------------------------- + + private function saveTmp(\GdImage $img, string $type): string + { + $path = sys_get_temp_dir() . '/crop_test_' . uniqid() . '.' . $type; + $this->tmpFiles[] = $path; + + if ($type === 'jpeg') { + imagejpeg($img, $path, 92); + } else { + imagepng($img, $path); + } + + return $path; + } + + public function tearDown(): void + { + foreach ($this->tmpFiles as $f) { + if (file_exists($f)) unlink($f); + } + parent::tearDown(); + } +}