Feature: ImageCropService – weißen Rand bei JPEG/PNG-Upload automatisch wegschneiden
This commit is contained in:
parent
dcfb0cc424
commit
72d30aa7fe
3 changed files with 310 additions and 1 deletions
|
|
@ -10,6 +10,7 @@ use Steuerkram\Models\Account;
|
||||||
use Steuerkram\Models\Supplier;
|
use Steuerkram\Models\Supplier;
|
||||||
use Steuerkram\Config;
|
use Steuerkram\Config;
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
|
use Steuerkram\Services\ImageCropService;
|
||||||
use Steuerkram\Services\KontierungsvorschlagService;
|
use Steuerkram\Services\KontierungsvorschlagService;
|
||||||
|
|
||||||
class BelegController
|
class BelegController
|
||||||
|
|
@ -93,7 +94,6 @@ class BelegController
|
||||||
$file = $_FILES['beleg_file'];
|
$file = $_FILES['beleg_file'];
|
||||||
$mime = mime_content_type($file['tmp_name']);
|
$mime = mime_content_type($file['tmp_name']);
|
||||||
$ext = self::ALLOWED_MIME[strtolower($mime)];
|
$ext = self::ALLOWED_MIME[strtolower($mime)];
|
||||||
$hash = hash_file('sha256', $file['tmp_name']);
|
|
||||||
$belegNr = Beleg::generateBelegNr();
|
$belegNr = Beleg::generateBelegNr();
|
||||||
$year = !empty($_POST['beleg_datum'])
|
$year = !empty($_POST['beleg_datum'])
|
||||||
? date('Y', strtotime($_POST['beleg_datum']))
|
? date('Y', strtotime($_POST['beleg_datum']))
|
||||||
|
|
@ -111,6 +111,14 @@ class BelegController
|
||||||
|
|
||||||
move_uploaded_file($file['tmp_name'], $absPath);
|
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([
|
$id = Beleg::create([
|
||||||
'beleg_nr' => $belegNr,
|
'beleg_nr' => $belegNr,
|
||||||
'filename' => $filename,
|
'filename' => $filename,
|
||||||
|
|
|
||||||
147
src/Services/ImageCropService.php
Normal file
147
src/Services/ImageCropService.php
Normal 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
154
tests/19_ImageCropTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue