Feature: Manueller Belegzuschnitt per Canvas-UI

This commit is contained in:
fds2610-bot 2026-04-10 20:20:42 +02:00
parent 517593ab03
commit aa36a16c5a
5 changed files with 277 additions and 0 deletions

View file

@ -51,6 +51,7 @@ $router->get('/belege/{id}', fn($p) => (new BelegController())->show($p));
$router->get('/belege/{id}/file', fn($p) => (new BelegController())->serveFile($p));
$router->post('/belege/{id}/delete', fn($p) => (new BelegController())->destroy($p));
$router->post('/belege/{id}/meta', fn($p) => (new BelegController())->updateMeta($p));
$router->post('/belege/{id}/crop', fn($p) => (new BelegController())->cropImage($p));
$router->post('/belege/{id}/vorschlag/generate', fn($p) => (new BelegController())->generateVorschlag($p));
$router->post('/belege/{id}/vorschlag/reset', fn($p) => (new BelegController())->resetVorschlag($p));

View file

@ -260,6 +260,58 @@ class BelegController
View::redirect('/belege', 'Beleg gelöscht.');
}
public function cropImage(array $params): void
{
header('Content-Type: application/json');
$id = (int)$params['id'];
$beleg = Beleg::find($id);
if (!$beleg) {
http_response_code(404);
echo json_encode(['error' => 'Beleg nicht gefunden.']);
return;
}
if ($beleg['locked']) {
http_response_code(403);
echo json_encode(['error' => 'GoBD: Gesperrter Beleg kann nicht verändert werden.']);
return;
}
if (!in_array($beleg['mime_type'], ['image/jpeg', 'image/png'], true)) {
http_response_code(422);
echo json_encode(['error' => 'Zuschneiden nur für JPEG und PNG möglich.']);
return;
}
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$x = (int)($body['x'] ?? 0);
$y = (int)($body['y'] ?? 0);
$w = (int)($body['w'] ?? 0);
$h = (int)($body['h'] ?? 0);
if ($w < 10 || $h < 10) {
http_response_code(422);
echo json_encode(['error' => 'Auswahlbereich zu klein (mindestens 10×10 Pixel).']);
return;
}
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
if (!file_exists($absPath)) {
http_response_code(404);
echo json_encode(['error' => 'Datei nicht gefunden.']);
return;
}
$svc = new ImageCropService();
if (!$svc->cropToRect($absPath, $x, $y, $w, $h)) {
http_response_code(500);
echo json_encode(['error' => 'Zuschnitt fehlgeschlagen.']);
return;
}
Beleg::updateHash($id, hash_file('sha256', $absPath));
echo json_encode(['ok' => true]);
}
private function renderView(string $template, array $data): string
{
extract($data, EXTR_SKIP);

View file

@ -218,6 +218,14 @@ class Beleg
);
}
public static function updateHash(int $id, string $hash): void
{
Database::execute(
'UPDATE belege SET file_hash = ?, updated_at = NOW() WHERE id = ?',
[$hash, $id]
);
}
public static function saveBelegText(int $id, string $text): void
{
Database::execute(

View file

@ -10,6 +10,50 @@ class ImageCropService
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 ein Bild auf den angegebenen Bereich zu (manueller Zuschnitt).
* Koordinaten und Maße beziehen sich auf das Originalbild in Pixeln.
* Gibt true zurück, wenn das Bild gespeichert wurde.
*/
public function cropToRect(string $path, int $x, int $y, int $w, int $h): 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;
}
$imgW = imagesx($img);
$imgH = imagesy($img);
// Koordinaten auf Bildgrenzen klemmen
$x = max(0, min($x, $imgW - 1));
$y = max(0, min($y, $imgH - 1));
$w = max(1, min($w, $imgW - $x));
$h = max(1, min($h, $imgH - $y));
$cropped = imagecrop($img, ['x' => $x, 'y' => $y, 'width' => $w, 'height' => $h]);
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;
}
/**
* Schneidet weißen/hellen Rand eines JPEG- oder PNG-Bilds automatisch weg.
* Gibt true zurück, wenn das Bild tatsächlich verändert wurde.

View file

@ -88,6 +88,9 @@ $suppliers = $suppliers ?? [];
</div>
<div style="margin-left:auto;display:flex;gap:.5rem">
<a href="/belege/<?= $h($beleg['id']) ?>/file" target="_blank" class="btn btn-secondary btn-sm">Datei öffnen</a>
<?php if (!$beleg['locked'] && in_array($beleg['mime_type'], ['image/jpeg', 'image/png'])): ?>
<button type="button" class="btn btn-secondary btn-sm" onclick="openCropModal()">Zuschneiden</button>
<?php endif; ?>
<?php if (!$beleg['locked']): ?>
<button type="button" class="btn btn-secondary btn-sm"
onclick="document.getElementById('beleg-meta-form').style.display = document.getElementById('beleg-meta-form').style.display === 'none' ? 'block' : 'none'">
@ -739,6 +742,175 @@ document.addEventListener('DOMContentLoaded', function () {
</script>
<?php endif; ?>
<?php if (!$beleg['locked'] && in_array($beleg['mime_type'], ['image/jpeg', 'image/png'])): ?>
<!-- Crop-Modal -->
<div class="modal-overlay" id="crop-modal-overlay" style="display:none;align-items:center;justify-content:center">
<div class="modal" style="max-width:95vw;width:auto;padding:1.25rem">
<div class="modal-title" style="margin-bottom:.75rem">Beleg zuschneiden</div>
<p class="text-sm text-muted" style="margin-bottom:.75rem">
Ziehe einen Rahmen über den gewünschten Ausschnitt. Das Original wird unwiderruflich ersetzt.
</p>
<div id="crop-canvas-wrap" style="position:relative;display:inline-block;cursor:crosshair;line-height:0;border:1px solid var(--color-border);border-radius:var(--radius)">
<canvas id="crop-canvas"></canvas>
</div>
<div style="margin-top:.75rem;display:flex;gap:.5rem;align-items:center">
<button type="button" class="btn btn-primary btn-sm" id="crop-confirm-btn" disabled onclick="confirmCrop()">Zuschnitt übernehmen</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="closeCropModal()">Abbrechen</button>
<span id="crop-size-hint" class="text-muted text-sm"></span>
</div>
</div>
</div>
<script>
(function() {
const BELEG_ID = <?= (int)$beleg['id'] ?>;
const FILE_URL = '/belege/<?= (int)$beleg['id'] ?>/file';
const overlay = document.getElementById('crop-modal-overlay');
const canvas = document.getElementById('crop-canvas');
const confirmBtn = document.getElementById('crop-confirm-btn');
const sizeHint = document.getElementById('crop-size-hint');
const ctx = canvas.getContext('2d');
let img = null;
let scaleX = 1, scaleY = 1;
let sel = null; // {x,y,w,h} in Canvas-Koordinaten
let dragging = false;
let startX = 0, startY = 0;
window.openCropModal = function() {
overlay.style.display = 'flex';
if (img) { drawCanvas(); return; }
img = new Image();
img.onload = function() {
// Canvas auf max. 90vw / 72vh skalieren
const maxW = Math.min(window.innerWidth * 0.90, 1200);
const maxH = Math.min(window.innerHeight * 0.72, 900);
const ratio = Math.min(maxW / img.naturalWidth, maxH / img.naturalHeight, 1);
canvas.width = Math.round(img.naturalWidth * ratio);
canvas.height = Math.round(img.naturalHeight * ratio);
scaleX = img.naturalWidth / canvas.width;
scaleY = img.naturalHeight / canvas.height;
sel = null;
drawCanvas();
};
img.src = FILE_URL + '?nocache=' + Date.now();
};
window.closeCropModal = function() {
overlay.style.display = 'none';
};
function drawCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
if (!sel || sel.w === 0 || sel.h === 0) return;
// Abdunkeln außerhalb der Auswahl
ctx.fillStyle = 'rgba(0,0,0,0.45)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Ausschnitt freistellen
ctx.drawImage(img,
sel.x * scaleX, sel.y * scaleY, sel.w * scaleX, sel.h * scaleY,
sel.x, sel.y, sel.w, sel.h);
// Rahmen
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 3]);
ctx.strokeRect(sel.x + 0.5, sel.y + 0.5, sel.w, sel.h);
ctx.setLineDash([]);
const realW = Math.round(sel.w * scaleX);
const realH = Math.round(sel.h * scaleY);
sizeHint.textContent = realW + ' × ' + realH + ' px';
confirmBtn.disabled = (realW < 10 || realH < 10);
}
function canvasPos(e) {
const r = canvas.getBoundingClientRect();
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
return {
x: Math.max(0, Math.min(canvas.width, Math.round(cx - r.left))),
y: Math.max(0, Math.min(canvas.height, Math.round(cy - r.top))),
};
}
canvas.addEventListener('mousedown', startDrag);
canvas.addEventListener('touchstart', startDrag, {passive: true});
function startDrag(e) {
const p = canvasPos(e);
startX = p.x; startY = p.y;
sel = {x: p.x, y: p.y, w: 0, h: 0};
dragging = true;
confirmBtn.disabled = true;
sizeHint.textContent = '';
}
window.addEventListener('mousemove', moveDrag);
window.addEventListener('touchmove', moveDrag, {passive: true});
function moveDrag(e) {
if (!dragging) return;
const p = canvasPos(e);
sel = {
x: Math.min(startX, p.x),
y: Math.min(startY, p.y),
w: Math.abs(p.x - startX),
h: Math.abs(p.y - startY),
};
drawCanvas();
}
window.addEventListener('mouseup', endDrag);
window.addEventListener('touchend', endDrag);
function endDrag() { dragging = false; }
window.confirmCrop = async function() {
if (!sel || sel.w < 1 || sel.h < 1) return;
confirmBtn.disabled = true;
confirmBtn.textContent = 'Wird gespeichert…';
const payload = {
x: Math.round(sel.x * scaleX),
y: Math.round(sel.y * scaleY),
w: Math.round(sel.w * scaleX),
h: Math.round(sel.h * scaleY),
};
try {
const res = await fetch('/belege/' + BELEG_ID + '/crop', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Unbekannter Fehler');
location.reload();
} catch (err) {
alert('Fehler beim Zuschneiden: ' + err.message);
confirmBtn.disabled = false;
confirmBtn.textContent = 'Zuschnitt übernehmen';
}
};
// ESC schließt Modal
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && overlay.style.display !== 'none') closeCropModal();
});
// Klick auf Overlay-Hintergrund schließt
overlay.addEventListener('click', function(e) {
if (e.target === overlay) closeCropModal();
});
}());
</script>
<?php endif; ?>
<?php if (!$beleg['locked']): ?>
<script>
document.addEventListener('DOMContentLoaded', function() {