From 500309ed53881b1749c81d4feb86d4a21b928bbe Mon Sep 17 00:00:00 2001 From: Jules Date: Sat, 21 Mar 2026 13:34:22 +0100 Subject: [PATCH] =?UTF-8?q?V2.2.0=20=E2=80=94=20Recadrage=20+=20Impression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Recadrage de pages (Pages > Recadrer) : - Mode marges : retirer X mm de chaque cote - Mode taille finale : definir la taille souhaitee (centrage auto) - Apercu en temps reel de la dimension finale - Appliquer a : page courante, toutes, paires, impaires, plage - Plage de pages personnalisee (ex: 1-3, 5, 8-10) - Impression (Fichier > Imprimer / Ctrl+P) : ouvre le PDF dans le viewer par defaut Co-Authored-By: Claude Opus 4.6 (1M context) --- app/dialogs/crop_dialog.py | 230 +++++++++++++++++++++++++++++++++++++ app/engine/crop.py | 43 +++++++ app/main_window.py | 41 +++++++ app/updater.py | 2 +- version.txt | 2 +- 5 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 app/dialogs/crop_dialog.py create mode 100644 app/engine/crop.py diff --git a/app/dialogs/crop_dialog.py b/app/dialogs/crop_dialog.py new file mode 100644 index 0000000..bdaa897 --- /dev/null +++ b/app/dialogs/crop_dialog.py @@ -0,0 +1,230 @@ +"""Dialogue Recadrer les pages.""" + +from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QGroupBox, QDoubleSpinBox, QComboBox, QRadioButton, + QDialogButtonBox, QLineEdit) +from app.engine.pdf_utils import mm_to_pt, pt_to_mm + + +class CropDialog(QDialog): + def __init__(self, page_width_pt=595, page_height_pt=842, total_pages=1, + current_page=0, parent=None): + super().__init__(parent) + self.setWindowTitle("Recadrer") + self.setMinimumWidth(450) + self._page_w = page_width_pt + self._page_h = page_height_pt + self._total_pages = total_pages + self._current_page = current_page + self._setup_ui() + self._update_preview() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # Info page actuelle + pw_mm = pt_to_mm(self._page_w) + ph_mm = pt_to_mm(self._page_h) + info = QLabel(f"Page actuelle : {pw_mm:.1f} x {ph_mm:.1f} mm") + info.setStyleSheet("color: #888; font-size: 11px;") + layout.addWidget(info) + + # Mode + grp_mode = QGroupBox("Mode de recadrage") + ml = QVBoxLayout(grp_mode) + self.rb_margins = QRadioButton("Par marges (retirer de chaque cote)") + self.rb_margins.setChecked(True) + self.rb_size = QRadioButton("Par taille finale") + ml.addWidget(self.rb_margins) + ml.addWidget(self.rb_size) + layout.addWidget(grp_mode) + + self.rb_margins.toggled.connect(self._on_mode_changed) + self.rb_size.toggled.connect(self._on_mode_changed) + + # Marges de recadrage + self.grp_margins_input = QGroupBox("Marges a retirer (mm)") + mgl = QVBoxLayout(self.grp_margins_input) + self.margin_spins = {} + row1 = QHBoxLayout() + row1.addStretch() + row1.addWidget(QLabel("Haut :")) + self.margin_spins["haut"] = QDoubleSpinBox() + self.margin_spins["haut"].setRange(0, 500) + self.margin_spins["haut"].setSuffix(" mm") + self.margin_spins["haut"].valueChanged.connect(self._update_preview) + row1.addWidget(self.margin_spins["haut"]) + row1.addStretch() + mgl.addLayout(row1) + + row2 = QHBoxLayout() + row2.addWidget(QLabel("Gauche :")) + self.margin_spins["gauche"] = QDoubleSpinBox() + self.margin_spins["gauche"].setRange(0, 500) + self.margin_spins["gauche"].setSuffix(" mm") + self.margin_spins["gauche"].valueChanged.connect(self._update_preview) + row2.addWidget(self.margin_spins["gauche"]) + row2.addStretch() + row2.addWidget(QLabel("Droite :")) + self.margin_spins["droite"] = QDoubleSpinBox() + self.margin_spins["droite"].setRange(0, 500) + self.margin_spins["droite"].setSuffix(" mm") + self.margin_spins["droite"].valueChanged.connect(self._update_preview) + row2.addWidget(self.margin_spins["droite"]) + mgl.addLayout(row2) + + row3 = QHBoxLayout() + row3.addStretch() + row3.addWidget(QLabel("Bas :")) + self.margin_spins["bas"] = QDoubleSpinBox() + self.margin_spins["bas"].setRange(0, 500) + self.margin_spins["bas"].setSuffix(" mm") + self.margin_spins["bas"].valueChanged.connect(self._update_preview) + row3.addWidget(self.margin_spins["bas"]) + row3.addStretch() + mgl.addLayout(row3) + layout.addWidget(self.grp_margins_input) + + # Taille finale + self.grp_size_input = QGroupBox("Taille finale (mm)") + sgl = QHBoxLayout(self.grp_size_input) + sgl.addWidget(QLabel("Largeur :")) + self.spin_final_w = QDoubleSpinBox() + self.spin_final_w.setRange(10, 5000) + self.spin_final_w.setValue(pt_to_mm(self._page_w)) + self.spin_final_w.setSuffix(" mm") + self.spin_final_w.valueChanged.connect(self._update_preview) + sgl.addWidget(self.spin_final_w) + sgl.addWidget(QLabel("Hauteur :")) + self.spin_final_h = QDoubleSpinBox() + self.spin_final_h.setRange(10, 5000) + self.spin_final_h.setValue(pt_to_mm(self._page_h)) + self.spin_final_h.setSuffix(" mm") + self.spin_final_h.valueChanged.connect(self._update_preview) + sgl.addWidget(self.spin_final_h) + layout.addWidget(self.grp_size_input) + self.grp_size_input.setVisible(False) + + # Apercu dimension finale + self.lbl_preview = QLabel() + self.lbl_preview.setStyleSheet("font-weight: bold; font-size: 13px; color: #0066cc; padding: 8px;") + layout.addWidget(self.lbl_preview) + + # Appliquer a + grp_apply = QGroupBox("Appliquer a") + al = QVBoxLayout(grp_apply) + self.rb_current = QRadioButton(f"Page courante ({self._current_page + 1})") + self.rb_current.setChecked(True) + self.rb_all = QRadioButton("Toutes les pages") + self.rb_even = QRadioButton("Pages paires") + self.rb_odd = QRadioButton("Pages impaires") + self.rb_range = QRadioButton("Plage de pages :") + al.addWidget(self.rb_current) + al.addWidget(self.rb_all) + al.addWidget(self.rb_even) + al.addWidget(self.rb_odd) + + range_layout = QHBoxLayout() + range_layout.addWidget(self.rb_range) + self.edit_range = QLineEdit() + self.edit_range.setPlaceholderText("ex: 1-3, 5, 8-10") + self.edit_range.setEnabled(False) + range_layout.addWidget(self.edit_range) + al.addLayout(range_layout) + layout.addWidget(grp_apply) + + self.rb_range.toggled.connect(self.edit_range.setEnabled) + + # Boutons + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _on_mode_changed(self): + self.grp_margins_input.setVisible(self.rb_margins.isChecked()) + self.grp_size_input.setVisible(self.rb_size.isChecked()) + self._update_preview() + + def _update_preview(self): + if self.rb_margins.isChecked(): + top = self.margin_spins["haut"].value() + bottom = self.margin_spins["bas"].value() + left = self.margin_spins["gauche"].value() + right = self.margin_spins["droite"].value() + w_mm = pt_to_mm(self._page_w) - left - right + h_mm = pt_to_mm(self._page_h) - top - bottom + else: + w_mm = self.spin_final_w.value() + h_mm = self.spin_final_h.value() + + if w_mm <= 0 or h_mm <= 0: + self.lbl_preview.setText("Dimensions invalides !") + self.lbl_preview.setStyleSheet("font-weight: bold; font-size: 13px; color: red; padding: 8px;") + else: + self.lbl_preview.setText(f"Resultat : {w_mm:.1f} x {h_mm:.1f} mm") + self.lbl_preview.setStyleSheet("font-weight: bold; font-size: 13px; color: #0066cc; padding: 8px;") + + def _parse_range(self): + """Parser la plage de pages. Retourne une liste d'indices (0-based).""" + text = self.edit_range.text().strip() + if not text: + return [] + indices = [] + for part in text.split(","): + part = part.strip() + if "-" in part: + try: + start, end = part.split("-", 1) + for p in range(int(start), int(end) + 1): + if 1 <= p <= self._total_pages: + indices.append(p - 1) + except ValueError: + pass + else: + try: + p = int(part) + if 1 <= p <= self._total_pages: + indices.append(p - 1) + except ValueError: + pass + return sorted(set(indices)) + + def get_settings(self): + # Mode + if self.rb_margins.isChecked(): + mode = "margins" + crop_top = mm_to_pt(self.margin_spins["haut"].value()) + crop_bottom = mm_to_pt(self.margin_spins["bas"].value()) + crop_left = mm_to_pt(self.margin_spins["gauche"].value()) + crop_right = mm_to_pt(self.margin_spins["droite"].value()) + else: + mode = "size" + final_w = mm_to_pt(self.spin_final_w.value()) + final_h = mm_to_pt(self.spin_final_h.value()) + crop_left = (self._page_w - final_w) / 2 + crop_right = crop_left + crop_top = (self._page_h - final_h) / 2 + crop_bottom = crop_top + + # Pages cibles + if self.rb_current.isChecked(): + pages = [self._current_page] + elif self.rb_all.isChecked(): + pages = list(range(self._total_pages)) + elif self.rb_even.isChecked(): + pages = [i for i in range(self._total_pages) if (i + 1) % 2 == 0] + elif self.rb_odd.isChecked(): + pages = [i for i in range(self._total_pages) if (i + 1) % 2 != 0] + else: + pages = self._parse_range() + + return { + "crop_top": crop_top, + "crop_bottom": crop_bottom, + "crop_left": crop_left, + "crop_right": crop_right, + "pages": pages, + } diff --git a/app/engine/crop.py b/app/engine/crop.py new file mode 100644 index 0000000..69bdb17 --- /dev/null +++ b/app/engine/crop.py @@ -0,0 +1,43 @@ +"""Moteur Recadrage de pages.""" + +from pypdf import PdfReader, PdfWriter +from .pdf_utils import get_page_size + + +def crop_pages(input_path, output_path, crop_top=0, crop_bottom=0, + crop_left=0, crop_right=0, pages=None): + """Recadrer les pages specifiees. + + crop_top/bottom/left/right : en points, quantite a retirer de chaque cote + pages : liste d'indices (0-based) des pages a recadrer. None = toutes. + """ + reader = PdfReader(input_path) + writer = PdfWriter() + + if pages is None: + pages = list(range(len(reader.pages))) + + for i, page in enumerate(reader.pages): + if i in pages: + w, h = get_page_size(page) + new_left = float(page.mediabox.left) + crop_left + new_bottom = float(page.mediabox.bottom) + crop_bottom + new_right = float(page.mediabox.right) - crop_right + new_top = float(page.mediabox.top) - crop_top + + # Securite + if new_right <= new_left or new_top <= new_bottom: + writer.add_page(page) + continue + + page.mediabox.lower_left = (new_left, new_bottom) + page.mediabox.upper_right = (new_right, new_top) + page.cropbox = page.mediabox + if hasattr(page, 'trimbox'): + page.trimbox = page.mediabox + + writer.add_page(page) + + with open(output_path, "wb") as f: + writer.write(f) + return output_path diff --git a/app/main_window.py b/app/main_window.py index 1ca12cc..2cb7376 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -23,6 +23,7 @@ from app.dialogs.page_numbers_dialog import PageNumbersDialog from app.dialogs.masking_dialog import MaskingDialog from app.dialogs.bleeds_dialog import BleedsDialog from app.dialogs.crop_marks_dialog import CropMarksDialog +from app.dialogs.crop_dialog import CropDialog from app.engine.booklet import create_booklet from app.engine.nup import nup_pages @@ -34,6 +35,7 @@ from app.engine.page_numbers import add_page_numbers from app.engine.masking import apply_masking from app.engine.bleeds import define_bleeds from app.engine.crop_marks import add_crop_marks, remove_crop_marks +from app.engine.crop import crop_pages class MainWindow(QMainWindow): @@ -100,6 +102,7 @@ class MainWindow(QMainWindow): file_menu = menubar.addMenu("Fichier") self._add_action(file_menu, "Ouvrir PDF...", "Ctrl+O", self._open_file) self._add_action(file_menu, "Enregistrer le resultat...", "Ctrl+S", self._save_result) + self._add_action(file_menu, "Imprimer...", "Ctrl+P", self._do_print) file_menu.addSeparator() self._add_action(file_menu, "Fusionner des PDF...", "Ctrl+M", self._do_merge) file_menu.addSeparator() @@ -115,6 +118,8 @@ class MainWindow(QMainWindow): # Pages pages_menu = menubar.addMenu("Pages") + self._add_action(pages_menu, "Recadrer...", None, self._do_crop) + pages_menu.addSeparator() self._add_action(pages_menu, "Inserer des pages blanches", None, self._do_insert_blank) # Annotations @@ -494,6 +499,42 @@ class MainWindow(QMainWindow): self._hide_progress() QMessageBox.critical(self, "Erreur", str(e)) + def _do_crop(self): + if not self._require_file(): + return + from pypdf import PdfReader + from app.engine.pdf_utils import get_page_size + reader = PdfReader(self._current_file) + page = reader.pages[self._viewer.current_page] + w, h = get_page_size(page) + dlg = CropDialog( + page_width_pt=w, page_height_pt=h, + total_pages=len(reader.pages), + current_page=self._viewer.current_page, + parent=self + ) + if dlg.exec(): + self._show_progress() + settings = dlg.get_settings() + output = self._make_temp() + try: + crop_pages(self._current_file, output, **settings) + self._show_result(output) + except Exception as e: + self._hide_progress() + QMessageBox.critical(self, "Erreur", str(e)) + + def _do_print(self): + if not self._require_file(): + return + import subprocess + try: + source = self._result_file or self._current_file + subprocess.Popen(["cmd", "/c", "start", "", source], shell=True) + self._status_label.setText("Ouverture pour impression...") + except Exception as e: + QMessageBox.critical(self, "Erreur", f"Impossible d'imprimer :\n{e}") + def _do_crop_marks(self): if not self._require_file(): return diff --git a/app/updater.py b/app/updater.py index 83eec3d..42305c1 100644 --- a/app/updater.py +++ b/app/updater.py @@ -14,7 +14,7 @@ REPO = "jules/copydev-imposing-tool" BRANCH = "master" # Version actuelle (incrementee a chaque release) -VERSION = "2.1.0" +VERSION = "2.2.0" def get_remote_version(): diff --git a/version.txt b/version.txt index 7ec1d6d..ccbccc3 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.1.0 +2.2.0