Files
copydev-imposing-tool/app/pdf_viewer.py
Jules 202834a794 V3.6.0 — Demonter pages, masquage visuel, regles, bleeds, corrections
- Demonter les pages : couper en 2 sans reordonner (sous Delivretiser)
- Masquage visuel : selection au curseur sur l'apercu + plage de pages
- Regles horizontale/verticale en mm (menu Affichage)
- Fonds perdus : filigrane rouge pour visualiser les bleeds
- Reperes de coupe : marques a l'exterieur (page agrandie), 2 traits/coin
- Numerotation : couleur configurable via selecteur
- Decalage : Trim & Shift renomme en francais, creep -> chasse
- Recadrages multiples cumulatifs (plages differentes)
- Fix spinbox format personnalise (fleches aleatoires)
- Fix combo livret : option Automatique preservee

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:12:54 +02:00

606 lines
22 KiB
Python

"""Widget de visualisation PDF avec pypdfium2 — V3 avec selection + regles."""
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QScrollArea, QSizePolicy, QApplication)
from PyQt6.QtGui import (QImage, QPixmap, QWheelEvent, QPainter, QPen, QColor,
QMouseEvent, QCursor, QBrush, QFont)
from PyQt6.QtCore import Qt, pyqtSignal, QRect, QPoint, QSize
import pypdfium2 as pdfium
HANDLE_SIZE = 8 # taille des poignees de redimensionnement
RULER_SIZE = 24 # largeur/hauteur des regles en pixels
class SelectableImageLabel(QLabel):
"""QLabel avec rectangle de selection redimensionnable par les angles."""
selection_changed = pyqtSignal(float, float, float, float)
selection_done = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._select_mode = False
self._has_selection = False
self._rect = QRect()
self._drag_mode = None # None, "create", "move", "tl", "tr", "bl", "br", "t", "b", "l", "r"
self._drag_start = QPoint()
self._drag_rect_start = QRect()
def set_select_mode(self, enabled):
self._select_mode = enabled
if enabled:
self.setCursor(Qt.CursorShape.CrossCursor)
else:
self.setCursor(Qt.CursorShape.ArrowCursor)
def clear_selection(self):
self._has_selection = False
self._rect = QRect()
self.update()
def set_selection_from_margins(self, left_ratio, top_ratio, right_ratio, bottom_ratio):
if not self.pixmap():
return
pm = self.pixmap()
img_w, img_h = pm.width(), pm.height()
off_x, off_y = self._img_offset()
x1 = off_x + int(left_ratio * img_w)
y1 = off_y + int(top_ratio * img_h)
x2 = off_x + int(right_ratio * img_w)
y2 = off_y + int(bottom_ratio * img_h)
self._rect = QRect(QPoint(x1, y1), QPoint(x2, y2)).normalized()
self._has_selection = True
self.update()
def _img_offset(self):
if not self.pixmap():
return 0, 0
pm = self.pixmap()
return max(0, (self.width() - pm.width()) // 2), max(0, (self.height() - pm.height()) // 2)
def _handle_rects(self):
"""Retourne les rectangles des 8 poignees."""
if not self._has_selection:
return {}
r = self._rect.normalized()
hs = HANDLE_SIZE
return {
"tl": QRect(r.left() - hs, r.top() - hs, hs * 2, hs * 2),
"tr": QRect(r.right() - hs, r.top() - hs, hs * 2, hs * 2),
"bl": QRect(r.left() - hs, r.bottom() - hs, hs * 2, hs * 2),
"br": QRect(r.right() - hs, r.bottom() - hs, hs * 2, hs * 2),
"t": QRect(r.center().x() - hs, r.top() - hs, hs * 2, hs * 2),
"b": QRect(r.center().x() - hs, r.bottom() - hs, hs * 2, hs * 2),
"l": QRect(r.left() - hs, r.center().y() - hs, hs * 2, hs * 2),
"r": QRect(r.right() - hs, r.center().y() - hs, hs * 2, hs * 2),
}
def _cursor_for_handle(self, handle):
cursors = {
"tl": Qt.CursorShape.SizeFDiagCursor,
"br": Qt.CursorShape.SizeFDiagCursor,
"tr": Qt.CursorShape.SizeBDiagCursor,
"bl": Qt.CursorShape.SizeBDiagCursor,
"t": Qt.CursorShape.SizeVerCursor,
"b": Qt.CursorShape.SizeVerCursor,
"l": Qt.CursorShape.SizeHorCursor,
"r": Qt.CursorShape.SizeHorCursor,
}
return cursors.get(handle, Qt.CursorShape.CrossCursor)
def mousePressEvent(self, event):
if not self._select_mode or event.button() != Qt.MouseButton.LeftButton:
return super().mousePressEvent(event)
pos = event.pos()
self._drag_start = pos
self._drag_rect_start = QRect(self._rect)
# Verifier si on clique sur une poignee
if self._has_selection:
for name, hr in self._handle_rects().items():
if hr.contains(pos):
self._drag_mode = name
return
# Clic dans le rectangle = deplacer
if self._rect.normalized().contains(pos):
self._drag_mode = "move"
return
# Nouveau rectangle
self._drag_mode = "create"
self._rect = QRect(pos, pos)
self._has_selection = True
self.update()
def mouseMoveEvent(self, event):
pos = event.pos()
if not self._select_mode:
return super().mouseMoveEvent(event)
# Hover : changer le curseur sur les poignees
if not self._drag_mode and self._has_selection:
for name, hr in self._handle_rects().items():
if hr.contains(pos):
self.setCursor(self._cursor_for_handle(name))
return
if self._rect.normalized().contains(pos):
self.setCursor(Qt.CursorShape.SizeAllCursor)
return
self.setCursor(Qt.CursorShape.CrossCursor)
return
if not self._drag_mode:
return
dx = pos.x() - self._drag_start.x()
dy = pos.y() - self._drag_start.y()
r = QRect(self._drag_rect_start)
if self._drag_mode == "create":
self._rect = QRect(self._drag_start, pos).normalized()
elif self._drag_mode == "move":
self._rect = r.translated(dx, dy)
elif self._drag_mode == "tl":
self._rect = QRect(QPoint(r.left() + dx, r.top() + dy), r.bottomRight()).normalized()
elif self._drag_mode == "tr":
self._rect = QRect(QPoint(r.left(), r.top() + dy), QPoint(r.right() + dx, r.bottom())).normalized()
elif self._drag_mode == "bl":
self._rect = QRect(QPoint(r.left() + dx, r.top()), QPoint(r.right(), r.bottom() + dy)).normalized()
elif self._drag_mode == "br":
self._rect = QRect(r.topLeft(), QPoint(r.right() + dx, r.bottom() + dy)).normalized()
elif self._drag_mode == "t":
self._rect = QRect(QPoint(r.left(), r.top() + dy), r.bottomRight()).normalized()
elif self._drag_mode == "b":
self._rect = QRect(r.topLeft(), QPoint(r.right(), r.bottom() + dy)).normalized()
elif self._drag_mode == "l":
self._rect = QRect(QPoint(r.left() + dx, r.top()), r.bottomRight()).normalized()
elif self._drag_mode == "r":
self._rect = QRect(r.topLeft(), QPoint(r.right() + dx, r.bottom())).normalized()
self.update()
self._emit_ratios()
def mouseReleaseEvent(self, event):
if self._drag_mode:
self._drag_mode = None
self._emit_ratios()
self.selection_done.emit()
self.update()
else:
super().mouseReleaseEvent(event)
def _emit_ratios(self):
if not self.pixmap() or not self._has_selection:
return
pm = self.pixmap()
img_w, img_h = pm.width(), pm.height()
if img_w <= 0 or img_h <= 0:
return
off_x, off_y = self._img_offset()
r = self._rect.normalized()
left = max(0, min(1, (r.left() - off_x) / img_w))
top = max(0, min(1, (r.top() - off_y) / img_h))
right = max(0, min(1, (r.right() - off_x) / img_w))
bottom = max(0, min(1, (r.bottom() - off_y) / img_h))
self.selection_changed.emit(left, top, right, bottom)
def paintEvent(self, event):
super().paintEvent(event)
if not self._has_selection or self._rect.isNull():
return
painter = QPainter(self)
r = self._rect.normalized()
full = self.rect()
# Zone sombre en dehors
painter.setBrush(QColor(0, 0, 0, 80))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRect(full.left(), full.top(), full.width(), r.top() - full.top())
painter.drawRect(full.left(), r.bottom(), full.width(), full.bottom() - r.bottom())
painter.drawRect(full.left(), r.top(), r.left() - full.left(), r.height())
painter.drawRect(r.right(), r.top(), full.right() - r.right(), r.height())
# Cadre
pen = QPen(QColor(0, 180, 216), 2, Qt.PenStyle.SolidLine)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(r)
# Poignees
painter.setBrush(QColor(255, 255, 255))
painter.setPen(QPen(QColor(0, 180, 216), 1))
for hr in self._handle_rects().values():
painter.drawRect(hr)
# Dimensions
if self.pixmap():
pm = self.pixmap()
img_w, img_h = pm.width(), pm.height()
off_x, off_y = self._img_offset()
if img_w > 0 and img_h > 0:
w_pct = r.width() / img_w * 100
h_pct = r.height() / img_h * 100
painter.setPen(QColor(0, 180, 216))
painter.drawText(r.left() + 4, r.top() - 6,
f"{w_pct:.0f}% x {h_pct:.0f}%")
painter.end()
class RulerWidget(QWidget):
"""Regle graduee en mm (horizontale ou verticale)."""
def __init__(self, orientation=Qt.Orientation.Horizontal, parent=None):
super().__init__(parent)
self._orientation = orientation
self._page_size_pt = 0 # taille de la page en points
self._zoom = 1.0
self._offset = 0 # decalage du scroll
self._img_offset = 0 # offset de l'image dans le label
if orientation == Qt.Orientation.Horizontal:
self.setFixedHeight(RULER_SIZE)
else:
self.setFixedWidth(RULER_SIZE)
def update_params(self, page_size_pt, zoom, offset, img_offset):
self._page_size_pt = page_size_pt
self._zoom = zoom
self._offset = offset
self._img_offset = img_offset
self.update()
def paintEvent(self, event):
if self._page_size_pt <= 0:
return
painter = QPainter(self)
painter.fillRect(self.rect(), QColor(245, 245, 245))
pen = QPen(QColor(120, 120, 120), 1)
painter.setPen(pen)
font = QFont("Segoe UI", 7)
painter.setFont(font)
# pixels par mm
px_per_pt = 150 / 72 * self._zoom
px_per_mm = px_per_pt * (72 / 25.4)
page_size_mm = self._page_size_pt * 25.4 / 72
# Position de depart de la page dans le widget
page_start = self._img_offset - self._offset
horiz = self._orientation == Qt.Orientation.Horizontal
# Determiner le pas des graduations
if px_per_mm >= 4:
step_mm = 5
sub_step_mm = 1
elif px_per_mm >= 2:
step_mm = 10
sub_step_mm = 5
else:
step_mm = 20
sub_step_mm = 10
# Dessiner les graduations
mm_val = 0
while mm_val <= page_size_mm:
px = page_start + mm_val * px_per_mm
if horiz:
if px < 0 or px > self.width():
mm_val += sub_step_mm
continue
else:
if px < 0 or px > self.height():
mm_val += sub_step_mm
continue
is_major = (mm_val % step_mm == 0)
tick_len = RULER_SIZE * 0.6 if is_major else RULER_SIZE * 0.3
if horiz:
x = int(px)
painter.drawLine(x, RULER_SIZE - int(tick_len), x, RULER_SIZE)
if is_major and mm_val > 0:
painter.drawText(x + 2, RULER_SIZE - int(tick_len) - 1, str(int(mm_val)))
else:
y = int(px)
painter.drawLine(RULER_SIZE - int(tick_len), y, RULER_SIZE, y)
if is_major and mm_val > 0:
painter.save()
painter.translate(RULER_SIZE - int(tick_len) - 2, y + 2)
painter.rotate(-90)
painter.drawText(0, 0, str(int(mm_val)))
painter.restore()
mm_val += sub_step_mm
# Ligne de bord
if horiz:
painter.drawLine(0, RULER_SIZE - 1, self.width(), RULER_SIZE - 1)
else:
painter.drawLine(RULER_SIZE - 1, 0, RULER_SIZE - 1, self.height())
painter.end()
class PdfViewer(QWidget):
"""Widget d'apercu PDF avec zoom molette, navigation, selection et regles."""
page_changed = pyqtSignal(int, int)
selection_changed = pyqtSignal(float, float, float, float)
selection_done = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._pdf = None
self._pdf_path = None
self._current_page = 0
self._total_pages = 0
self._zoom = 1.0
self._page_width_pt = 0
self._page_height_pt = 0
self._rulers_visible = False
self._bleed_info = None # dict avec top/bottom/left/right en pt
self._setup_ui()
def _setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Regle horizontale
self._h_ruler = RulerWidget(Qt.Orientation.Horizontal)
self._h_ruler.setVisible(False)
# Zone centrale : regle verticale + scroll
center_layout = QHBoxLayout()
center_layout.setContentsMargins(0, 0, 0, 0)
center_layout.setSpacing(0)
self._v_ruler = RulerWidget(Qt.Orientation.Vertical)
self._v_ruler.setVisible(False)
self._scroll = QScrollArea()
self._scroll.setWidgetResizable(True)
self._scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._scroll.setStyleSheet("QScrollArea { background: #d0d0d0; border: none; }")
self._image_label = SelectableImageLabel()
self._image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._image_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
self._image_label.setStyleSheet("background: #d0d0d0;")
self._image_label.selection_changed.connect(self.selection_changed.emit)
self._image_label.selection_done.connect(self.selection_done.emit)
self._scroll.setWidget(self._image_label)
self._scroll.wheelEvent = self._on_wheel
self._scroll.horizontalScrollBar().valueChanged.connect(self._update_rulers)
self._scroll.verticalScrollBar().valueChanged.connect(self._update_rulers)
center_layout.addWidget(self._v_ruler)
center_layout.addWidget(self._scroll, 1)
# Coin superieur gauche (vide) quand regles actives
h_ruler_layout = QHBoxLayout()
h_ruler_layout.setContentsMargins(0, 0, 0, 0)
h_ruler_layout.setSpacing(0)
self._ruler_corner = QWidget()
self._ruler_corner.setFixedSize(RULER_SIZE, RULER_SIZE)
self._ruler_corner.setStyleSheet("background: #f5f5f5;")
self._ruler_corner.setVisible(False)
h_ruler_layout.addWidget(self._ruler_corner)
h_ruler_layout.addWidget(self._h_ruler, 1)
layout.addLayout(h_ruler_layout)
layout.addLayout(center_layout)
def set_rulers_visible(self, visible):
self._rulers_visible = visible
self._h_ruler.setVisible(visible)
self._v_ruler.setVisible(visible)
self._ruler_corner.setVisible(visible)
if visible:
self._update_rulers()
def rulers_visible(self):
return self._rulers_visible
def set_bleed_info(self, bleed_info):
"""Definir les infos de bleed pour l'overlay rouge. None pour desactiver."""
self._bleed_info = bleed_info
self._render_page()
def set_select_mode(self, enabled):
self._image_label.set_select_mode(enabled)
def clear_selection(self):
self._image_label.clear_selection()
def show_selection(self, left_ratio, top_ratio, right_ratio, bottom_ratio):
self._image_label.set_selection_from_margins(left_ratio, top_ratio, right_ratio, bottom_ratio)
def _update_rulers(self):
if not self._rulers_visible or not self._pdf:
return
# Calculer l'offset de l'image dans le scroll area
pm = self._image_label.pixmap()
if not pm:
return
img_w, img_h = pm.width(), pm.height()
label_w = self._image_label.width()
label_h = self._image_label.height()
img_off_x = max(0, (label_w - img_w) // 2)
img_off_y = max(0, (label_h - img_h) // 2)
scroll_x = self._scroll.horizontalScrollBar().value()
scroll_y = self._scroll.verticalScrollBar().value()
self._h_ruler.update_params(self._page_width_pt, self._zoom, scroll_x, img_off_x)
self._v_ruler.update_params(self._page_height_pt, self._zoom, scroll_y, img_off_y)
def _on_wheel(self, event):
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
delta = event.angleDelta().y()
if delta > 0:
self._zoom = min(self._zoom * 1.15, 5.0)
else:
self._zoom = max(self._zoom / 1.15, 0.2)
self._render_page()
else:
if event.angleDelta().y() < 0:
self.next_page()
elif event.angleDelta().y() > 0:
self.prev_page()
def load_pdf(self, path):
try:
self._pdf_path = path
self._pdf = pdfium.PdfDocument(path)
self._total_pages = len(self._pdf)
self._current_page = 0
self._zoom_fit()
self._render_page()
except Exception as e:
self._image_label.setText(f"Erreur: {e}")
def load_pdf_bytes(self, data):
try:
self._pdf = pdfium.PdfDocument(data)
self._total_pages = len(self._pdf)
self._current_page = 0
self._zoom_fit()
self._render_page()
except Exception as e:
self._image_label.setText(f"Erreur: {e}")
def _render_page(self):
if not self._pdf or self._current_page >= self._total_pages:
return
page = self._pdf[self._current_page]
self._page_width_pt, self._page_height_pt = page.get_size()
scale = 150 / 72 * self._zoom
bitmap = page.render(scale=scale)
pil_image = bitmap.to_pil()
data = pil_image.tobytes("raw", "RGBA")
qimage = QImage(data, pil_image.width, pil_image.height, QImage.Format.Format_RGBA8888)
# Overlay bleed si actif
if self._bleed_info:
from PyQt6.QtGui import QPainter as _P
px_per_pt = scale
bt = self._bleed_info.get("top", 0) * px_per_pt
bb = self._bleed_info.get("bottom", 0) * px_per_pt
bl = self._bleed_info.get("left", 0) * px_per_pt
br = self._bleed_info.get("right", 0) * px_per_pt
img_w = qimage.width()
img_h = qimage.height()
painter = _P(qimage)
painter.setBrush(QColor(255, 0, 0, 50))
painter.setPen(QPen(QColor(255, 0, 0, 120), 1, Qt.PenStyle.DashLine))
# Haut
if bt > 0:
painter.drawRect(0, 0, img_w, int(bt))
# Bas
if bb > 0:
painter.drawRect(0, img_h - int(bb), img_w, int(bb))
# Gauche
if bl > 0:
painter.drawRect(0, int(bt), int(bl), img_h - int(bt) - int(bb))
# Droite
if br > 0:
painter.drawRect(img_w - int(br), int(bt), int(br), img_h - int(bt) - int(bb))
painter.end()
pixmap = QPixmap.fromImage(qimage)
self._image_label.setPixmap(pixmap)
self._image_label.adjustSize()
self.page_changed.emit(self._current_page, self._total_pages)
if self._rulers_visible:
self._update_rulers()
def _zoom_fit(self):
if not self._pdf or self._current_page >= self._total_pages:
return
page = self._pdf[self._current_page]
pw, ph = page.get_size()
vw = self._scroll.viewport().width() - 20
vh = self._scroll.viewport().height() - 20
if vw <= 0 or vh <= 0:
self._zoom = 1.0
return
self._zoom = min(vw / pw, vh / ph) * (72 / 150)
def prev_page(self):
if self._current_page > 0:
self._current_page -= 1
self._render_page()
def next_page(self):
if self._current_page < self._total_pages - 1:
self._current_page += 1
self._render_page()
def go_to_page(self, page_index):
if 0 <= page_index < self._total_pages and page_index != self._current_page:
self._current_page = page_index
self._render_page()
def zoom_in(self):
self._zoom = min(self._zoom * 1.25, 5.0)
self._render_page()
def zoom_out(self):
self._zoom = max(self._zoom / 1.25, 0.2)
self._render_page()
def zoom_fit(self):
self._zoom_fit()
self._render_page()
def refresh(self):
self._render_page()
def get_page_thumbnail(self, page_index, width=120):
if not self._pdf or page_index >= self._total_pages:
return None
page = self._pdf[page_index]
pw, ph = page.get_size()
scale = width / pw
bitmap = page.render(scale=scale)
pil_image = bitmap.to_pil()
data = pil_image.tobytes("raw", "RGBA")
qimage = QImage(data, pil_image.width, pil_image.height, QImage.Format.Format_RGBA8888)
return QPixmap.fromImage(qimage)
@property
def current_page(self):
return self._current_page
@property
def total_pages(self):
return self._total_pages
@property
def zoom_level(self):
return self._zoom
@property
def pdf_path(self):
return self._pdf_path
@property
def page_width_pt(self):
return self._page_width_pt
@property
def page_height_pt(self):
return self._page_height_pt