Feature: ImageCropService – weißen Rand bei JPEG/PNG-Upload automatisch wegschneiden

This commit is contained in:
fds2610-bot 2026-04-10 19:38:34 +02:00
parent dcfb0cc424
commit 72d30aa7fe
3 changed files with 310 additions and 1 deletions

View file

@ -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,

View file

@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
class ImageCropService
{
private const FUZZ = 20; // Pixel-Abweichung von Weiß, die noch als Rand gilt (0–255)
private const PADDING = 25; // Pixel-Abstand, der nach dem Zuschnitt ringsum erhalten bleibt
private const MIN_CROP_GAIN = 0.03; // Mindestens 3 % Einsparung, sonst kein Zuschnitt
/**
* Schneidet weißen/hellen Rand eines JPEG- oder PNG-Bilds automatisch weg.
* Gibt true zurück, wenn das Bild tatsächlich verändert wurde.
*/
public function cropWhitespace(string $path): bool
{
$mime = mime_content_type($path);
$img = match ($mime) {
'image/jpeg' => 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);
}
}

154
tests/19_ImageCropTest.php Normal file
View file

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
/**
* Test 19: ImageCropService – automatisches Wegschneiden weißer Ränder.
*
* Erstellt synthetische GD-Bilder im Arbeitsspeicher, speichert sie als
* temporäre Dateien und prüft, ob cropWhitespace() korrekt zuschneidet.
* Kein Datenbankzugriff nötig.
*/
class ImageCropTest extends TestCase
{
protected string $testName = 'Test 19: ImageCropService – Weißrand-Zuschnitt';
/** @var string[] */
private array $tmpFiles = [];
public function run(): void
{
$this->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();
}
}