Crop : ratio verrouillé, bord à bord, pas de marge

- Redimensionnement verrouille le ratio (coins uniquement)
- Cadre le plus grand possible (100% d'un bord, pas de marge)
- Déplacement contraint dans les limites de la photo
- Même comportement dans l'éditeur et l'écran impression

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 23:42:39 +01:00
parent 980a80d43a
commit 190c914d45
3 changed files with 118 additions and 39 deletions

View File

@@ -158,7 +158,7 @@ class EditorScreen(QWidget):
# --- Cadre ratio suggéré ---
def _show_ratio_crop(self):
"""Affiche un cadre de recadrage au ratio 3:2 en suggestion."""
"""Affiche un cadre de recadrage au ratio 3:2, le plus grand possible."""
if not self.pixmap_item:
return
self.is_cropping = True
@@ -171,14 +171,17 @@ class EditorScreen(QWidget):
if img_w < img_h:
ratio = 1.0 / ratio
# Plus grande zone possible
# Le plus grand possible : bord à bord, pas de marge
if img_w / img_h > ratio:
crop_h = img_h * 0.95
# Image plus large → hauteur = 100%, largeur ajustée
crop_h = img_h
crop_w = crop_h * ratio
else:
crop_w = img_w * 0.95
# Image plus haute → largeur = 100%, hauteur ajustée
crop_w = img_w
crop_h = crop_w / ratio
# Centrer
x = (img_w - crop_w) / 2
y = (img_h - crop_h) / 2

View File

@@ -196,17 +196,15 @@ class PrintScreen(QWidget):
# Image portrait → cadre portrait (inverser le ratio)
ratio = 1.0 / ratio
# Calculer la plus grande zone de crop possible dans l'image
# Le plus grand possible : bord à bord, pas de marge
if img_w / img_h > ratio:
# Image plus large que le ratio → hauteur max, largeur ajustée
crop_h = img_h * 0.9
crop_h = img_h
crop_w = crop_h * ratio
else:
# Image plus haute que le ratio → largeur max, hauteur ajustée
crop_w = img_w * 0.9
crop_w = img_w
crop_h = crop_w / ratio
# Centrer le cadre
# Centrer
x = (img_w - crop_w) / 2
y = (img_h - crop_h) / 2

View File

@@ -8,7 +8,7 @@ HANDLE_SIZE = 20
class CropOverlay(QGraphicsRectItem):
"""Rectangle de recadrage avec poignées tactiles redimensionnables."""
"""Rectangle de recadrage avec ratio verrouillé et poignées tactiles."""
def __init__(self, rect, parent=None):
super().__init__(rect, parent)
@@ -19,15 +19,41 @@ class CropOverlay(QGraphicsRectItem):
self.setAcceptHoverEvents(True)
self._resizing = False
self._resize_edge = None
self._resize_corner = None
self._start_rect = None
self._start_pos = None
self._bounds = None
# Ratio verrouillé (largeur / hauteur)
self._locked_ratio = rect.width() / rect.height() if rect.height() > 0 else 1.5
def set_bounds(self, rect):
"""Limites dans lesquelles le crop peut bouger (la photo)."""
self._bounds = rect
def set_ratio(self, ratio):
"""Change le ratio verrouillé."""
self._locked_ratio = ratio
def itemChange(self, change, value):
"""Empêche le cadre de sortir des limites de la photo."""
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and self._bounds:
r = self.rect()
new_pos = value
b = self._bounds
# Contraindre le déplacement
if new_pos.x() + r.left() < b.left():
new_pos.setX(b.left() - r.left())
if new_pos.y() + r.top() < b.top():
new_pos.setY(b.top() - r.top())
if new_pos.x() + r.right() > b.right():
new_pos.setX(b.right() - r.right())
if new_pos.y() + r.bottom() > b.bottom():
new_pos.setY(b.bottom() - r.bottom())
return new_pos
return super().itemChange(change, value)
def paint(self, painter, option, widget=None):
super().paint(painter, option, widget)
r = self.rect()
@@ -36,7 +62,6 @@ class CropOverlay(QGraphicsRectItem):
if self._bounds:
painter.setBrush(QBrush(QColor(0, 0, 0, 120)))
painter.setPen(Qt.PenStyle.NoPen)
# Les 4 zones sombres autour du crop
b = self.mapRectFromScene(self._bounds)
# Haut
painter.drawRect(QRectF(b.left(), b.top(), b.width(), r.top() - b.top()))
@@ -64,10 +89,10 @@ class CropOverlay(QGraphicsRectItem):
def mousePressEvent(self, event):
pos = event.pos()
edge = self._detect_edge(pos)
if edge:
corner = self._detect_corner(pos)
if corner:
self._resizing = True
self._resize_edge = edge
self._resize_corner = corner
self._start_rect = QRectF(self.rect())
self._start_pos = pos
event.accept()
@@ -78,19 +103,68 @@ class CropOverlay(QGraphicsRectItem):
if self._resizing:
delta = event.pos() - self._start_pos
r = QRectF(self._start_rect)
e = self._resize_edge
corner = self._resize_corner
if "left" in e:
r.setLeft(r.left() + delta.x())
if "right" in e:
r.setRight(r.right() + delta.x())
if "top" in e:
r.setTop(r.top() + delta.y())
if "bottom" in e:
r.setBottom(r.bottom() + delta.y())
# Redimensionner en gardant le ratio
# On utilise le déplacement diagonal le plus grand
if "right" in corner:
dx = delta.x()
else:
dx = -delta.x()
if "bottom" in corner:
dy = delta.y()
else:
dy = -delta.y()
# Prendre le delta dominant
if abs(dx) > abs(dy):
new_w = r.width() + dx
new_h = new_w / self._locked_ratio
else:
new_h = r.height() + dy
new_w = new_h * self._locked_ratio
# Taille minimum
if r.width() >= 50 and r.height() >= 50:
if new_w < 50 or new_h < 50:
event.accept()
return
# Appliquer selon le coin tiré
if corner == "bottomright":
r.setWidth(new_w)
r.setHeight(new_h)
elif corner == "bottomleft":
r.setLeft(r.right() - new_w)
r.setHeight(new_h)
elif corner == "topright":
r.setWidth(new_w)
r.setTop(r.bottom() - new_h)
elif corner == "topleft":
r.setLeft(r.right() - new_w)
r.setTop(r.bottom() - new_h)
# Contraindre aux limites de la photo
if self._bounds:
b = self.mapRectFromScene(self._bounds)
# Ne pas dépasser les bords
if r.left() < b.left():
r.moveLeft(b.left())
if r.top() < b.top():
r.moveTop(b.top())
if r.right() > b.right():
over = r.right() - b.right()
new_w = r.width() - over
new_h = new_w / self._locked_ratio
r.setWidth(new_w)
r.setHeight(new_h)
if r.bottom() > b.bottom():
over = r.bottom() - b.bottom()
new_h = r.height() - over
new_w = new_h * self._locked_ratio
r.setWidth(new_w)
r.setHeight(new_h)
self.setRect(r)
event.accept()
else:
@@ -99,24 +173,28 @@ class CropOverlay(QGraphicsRectItem):
def mouseReleaseEvent(self, event):
if self._resizing:
self._resizing = False
self._resize_edge = None
self._resize_corner = None
event.accept()
else:
super().mouseReleaseEvent(event)
def _detect_edge(self, pos):
def _detect_corner(self, pos):
r = self.rect()
margin = HANDLE_SIZE
edges = ""
if abs(pos.y() - r.top()) < margin:
edges += "top"
elif abs(pos.y() - r.bottom()) < margin:
edges += "bottom"
if abs(pos.x() - r.left()) < margin:
edges += "left"
elif abs(pos.x() - r.right()) < margin:
edges += "right"
return edges or None
near_top = abs(pos.y() - r.top()) < margin
near_bottom = abs(pos.y() - r.bottom()) < margin
near_left = abs(pos.x() - r.left()) < margin
near_right = abs(pos.x() - r.right()) < margin
if near_top and near_left:
return "topleft"
if near_top and near_right:
return "topright"
if near_bottom and near_left:
return "bottomleft"
if near_bottom and near_right:
return "bottomright"
return None
def get_crop_rect(self):
"""Retourne le rectangle de crop en coordonnées scène."""