diff --git a/app/dialogs/trim_shift_dialog.py b/app/dialogs/trim_shift_dialog.py new file mode 100644 index 0000000..bc02085 --- /dev/null +++ b/app/dialogs/trim_shift_dialog.py @@ -0,0 +1,84 @@ +"""Dialogue Trim & Shift.""" + +from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QGroupBox, QDoubleSpinBox, QCheckBox, + QDialogButtonBox) +from app.engine.pdf_utils import mm_to_pt + + +class TrimShiftDialog(QDialog): + def __init__(self, total_pages=1, parent=None): + super().__init__(parent) + self.setWindowTitle("Trim && Shift — Decaler le contenu") + self.setMinimumWidth(420) + self._total_pages = total_pages + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # Decalage pages impaires + grp_odd = QGroupBox("Decalage pages impaires (mm)") + ol = QHBoxLayout(grp_odd) + ol.addWidget(QLabel("X :")) + self.spin_x = QDoubleSpinBox() + self.spin_x.setRange(-200, 200) + self.spin_x.setSuffix(" mm") + ol.addWidget(self.spin_x) + ol.addWidget(QLabel("Y :")) + self.spin_y = QDoubleSpinBox() + self.spin_y.setRange(-200, 200) + self.spin_y.setSuffix(" mm") + ol.addWidget(self.spin_y) + layout.addWidget(grp_odd) + + # Pages paires differentes + self.chk_different = QCheckBox("Decalage different pour pages paires") + layout.addWidget(self.chk_different) + + grp_even = QGroupBox("Decalage pages paires (mm)") + el = QHBoxLayout(grp_even) + el.addWidget(QLabel("X :")) + self.spin_x_even = QDoubleSpinBox() + self.spin_x_even.setRange(-200, 200) + self.spin_x_even.setSuffix(" mm") + self.spin_x_even.setEnabled(False) + el.addWidget(self.spin_x_even) + el.addWidget(QLabel("Y :")) + self.spin_y_even = QDoubleSpinBox() + self.spin_y_even.setRange(-200, 200) + self.spin_y_even.setSuffix(" mm") + self.spin_y_even.setEnabled(False) + el.addWidget(self.spin_y_even) + layout.addWidget(grp_even) + + self.chk_different.toggled.connect(self.spin_x_even.setEnabled) + self.chk_different.toggled.connect(self.spin_y_even.setEnabled) + + # Creep + grp_creep = QGroupBox("Compensation de creep (livrets)") + cl = QHBoxLayout(grp_creep) + cl.addWidget(QLabel("Creep max :")) + self.spin_creep = QDoubleSpinBox() + self.spin_creep.setRange(0, 20) + self.spin_creep.setSuffix(" mm") + self.spin_creep.setToolTip("Decalage progressif pour compenser l'epaisseur du papier") + cl.addWidget(self.spin_creep) + layout.addWidget(grp_creep) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def get_settings(self): + return { + "shift_x": mm_to_pt(self.spin_x.value()), + "shift_y": mm_to_pt(self.spin_y.value()), + "shift_x_even": mm_to_pt(self.spin_x_even.value()) if self.chk_different.isChecked() else None, + "shift_y_even": mm_to_pt(self.spin_y_even.value()) if self.chk_different.isChecked() else None, + "creep": mm_to_pt(self.spin_creep.value()), + "creep_pages": self._total_pages, + } diff --git a/app/engine/trim_shift.py b/app/engine/trim_shift.py new file mode 100644 index 0000000..f8213bb --- /dev/null +++ b/app/engine/trim_shift.py @@ -0,0 +1,59 @@ +"""Moteur Trim & Shift — decaler le contenu des pages.""" + +from pypdf import PdfReader, PdfWriter, PageObject, Transformation +from .pdf_utils import get_page_size + + +def trim_and_shift(input_path, output_path, shift_x=0, shift_y=0, + shift_x_even=None, shift_y_even=None, + creep=0, creep_pages=0): + """Decaler le contenu des pages. + + shift_x, shift_y: decalage en points (pages impaires si even specifie) + shift_x_even, shift_y_even: decalage pour pages paires (None = meme que impaires) + creep: decalage progressif en points (compensation epaisseur papier) + creep_pages: nombre total de pages pour le calcul du creep + """ + reader = PdfReader(input_path) + writer = PdfWriter() + num_pages = len(reader.pages) + + if shift_x_even is None: + shift_x_even = shift_x + if shift_y_even is None: + shift_y_even = shift_y + + for i, page in enumerate(reader.pages): + page_num = i + 1 + w, h = get_page_size(page) + + # Decalage de base + if page_num % 2 == 0: + dx, dy = shift_x_even, shift_y_even + else: + dx, dy = shift_x, shift_y + + # Creep progressif (pour livrets) + if creep != 0 and creep_pages > 0: + # Les pages du centre se decalent le plus + mid = num_pages / 2 + distance_from_center = abs(i - mid) / mid # 0 au centre, 1 aux bords + creep_amount = creep * (1 - distance_from_center) + if page_num % 2 == 0: + dx -= creep_amount # pages paires : decaler vers la gauche + else: + dx += creep_amount # pages impaires : decaler vers la droite + + if dx != 0 or dy != 0: + new_page = PageObject.create_blank_page(width=w, height=h) + overlay = PageObject.create_blank_page(width=w, height=h) + overlay.merge_page(page) + overlay.add_transformation(Transformation().translate(dx, dy)) + new_page.merge_page(overlay) + writer.add_page(new_page) + else: + writer.add_page(page) + + with open(output_path, "wb") as f: + writer.write(f) + return output_path diff --git a/app/engine/unbooklet.py b/app/engine/unbooklet.py new file mode 100644 index 0000000..966e764 --- /dev/null +++ b/app/engine/unbooklet.py @@ -0,0 +1,73 @@ +"""Moteur Delivretiser — convertir un livret en pages sequentielles.""" + +from pypdf import PdfReader, PdfWriter, PageObject, Transformation +from .pdf_utils import get_page_size +import math + + +def unbooklet(input_path, output_path): + """Delivretiser un PDF en format livret. + + Chaque page du livret contient 2 pages cote a cote. + On les coupe en 2 et on les remet dans l'ordre sequentiel. + """ + reader = PdfReader(input_path) + num_sheets = len(reader.pages) + total_pages = num_sheets * 2 + + # Extraire toutes les demi-pages + half_pages = [] # (sheet_index, "left" ou "right") + for i in range(num_sheets): + half_pages.append((i, "left")) + half_pages.append((i, "right")) + + # Ordre du livret (piqure a cheval) : + # Feuille 0 recto : page N, page 1 + # Feuille 0 verso : page 2, page N-1 + # Feuille 1 recto : page N-2, page 3 + # etc. + # On reconstruit l'ordre + page_map = {} # page_number (1-based) -> (sheet_index, side) + n = total_pages + for i in range(num_sheets): + if i % 2 == 0: + # Recto : gauche = N - i, droite = i + 1 + page_map[n - i] = (i, "left") + page_map[i + 1] = (i, "right") + else: + # Verso : gauche = i + 1, droite = N - i + page_map[i + 1] = (i, "left") + page_map[n - i] = (i, "right") + + writer = PdfWriter() + + for page_num in range(1, total_pages + 1): + if page_num not in page_map: + continue + sheet_idx, side = page_map[page_num] + sheet = reader.pages[sheet_idx] + sheet_w, sheet_h = get_page_size(sheet) + half_w = sheet_w / 2 + + # Creer une nouvelle page avec la moitie + new_page = PageObject.create_blank_page(width=half_w, height=sheet_h) + + if side == "left": + # Copier la moitie gauche + overlay = PageObject.create_blank_page(width=half_w, height=sheet_h) + overlay.merge_page(sheet) + overlay.mediabox.upper_right = (half_w, sheet_h) + new_page.merge_page(overlay) + else: + # Copier la moitie droite + overlay = PageObject.create_blank_page(width=half_w, height=sheet_h) + overlay.merge_page(sheet) + overlay.add_transformation(Transformation().translate(-half_w, 0)) + overlay.mediabox.upper_right = (half_w, sheet_h) + new_page.merge_page(overlay) + + writer.add_page(new_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 0cc0c34..984cce8 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -39,6 +39,9 @@ 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 from app.engine.add_content import add_text_to_pages, add_image_to_pages +from app.engine.unbooklet import unbooklet +from app.engine.trim_shift import trim_and_shift +from app.dialogs.trim_shift_dialog import TrimShiftDialog class MainWindow(QMainWindow): @@ -56,8 +59,10 @@ class MainWindow(QMainWindow): self._current_file = None self._result_file = None - self._original_file = None # fichier avant crop (pour recadrage non-destructif) - self._crop_settings = {} # {page_index: {top, bottom, left, right} en pt} + self._original_file = None + self._crop_settings = {} + self._undo_stack = [] # pile de fichiers pour annuler + self._redo_stack = [] # Drag & drop self.setAcceptDrops(True) @@ -200,6 +205,11 @@ class MainWindow(QMainWindow): file_menu.addSeparator() self._add_action(file_menu, "Quitter", "Ctrl+Q", self.close) + # Edition + edit_menu = menubar.addMenu("Edition") + self._add_action(edit_menu, "Annuler", "Ctrl+Z", self._do_undo) + self._add_action(edit_menu, "Refaire", "Ctrl+Y", self._do_redo) + # Imposition impose_menu = menubar.addMenu("Imposition") self._add_action(impose_menu, "Creer un livret", None, self._do_booklet) @@ -207,10 +217,18 @@ class MainWindow(QMainWindow): self._add_action(impose_menu, "Repetition (Step && Repeat)", None, self._do_step_repeat) self._add_action(impose_menu, "Imposition manuelle", None, self._do_manual_impose) self._add_action(impose_menu, "Joindre deux pages", None, self._do_join_pages) + impose_menu.addSeparator() + self._add_action(impose_menu, "Delivretiser", None, self._do_unbooklet) # Pages pages_menu = menubar.addMenu("Pages") self._add_action(pages_menu, "Recadrer...", None, self._do_crop) + self._add_action(pages_menu, "Trim && Shift...", None, self._do_trim_shift) + pages_menu.addSeparator() + self._add_action(pages_menu, "Pivoter +90", None, lambda: self._rotate_current(90)) + self._add_action(pages_menu, "Pivoter -90", None, lambda: self._rotate_current(-90)) + self._add_action(pages_menu, "Pivoter 180", None, lambda: self._rotate_current(180)) + self._add_action(pages_menu, "Inverser l'ordre des pages", None, self._do_reverse_pages) pages_menu.addSeparator() self._add_action(pages_menu, "Inserer des pages blanches", None, self._do_insert_blank) @@ -284,6 +302,7 @@ class MainWindow(QMainWindow): # Section Imposition rtb.addWidget(self._make_section_label("IMPOSITION")) rtb.addAction("Livret", self._do_booklet) + rtb.addAction("Delivretiser", self._do_unbooklet) rtb.addAction("N-Up", self._do_nup) rtb.addAction("Repetition", self._do_step_repeat) rtb.addAction("Manuel", self._do_manual_impose) @@ -293,7 +312,9 @@ class MainWindow(QMainWindow): # Section Pages rtb.addWidget(self._make_section_label("PAGES")) rtb.addAction("Recadrer", self._do_crop) + rtb.addAction("Trim/Shift", self._do_trim_shift) rtb.addAction("Pg blanche", self._do_insert_blank) + rtb.addAction("Inverser", self._do_reverse_pages) rtb.addAction("Fusionner", self._do_merge) rtb.addSeparator() @@ -340,6 +361,7 @@ class MainWindow(QMainWindow): self._page_panel.page_delete_requested.connect(self._on_page_delete) self._page_panel.page_insert_requested.connect(self._on_page_insert) self._page_panel.pages_reordered.connect(self._on_pages_reordered) + self._page_panel.page_rotate_requested.connect(self._on_page_rotate) splitter.addWidget(self._page_panel) # Viewer PDF @@ -488,6 +510,12 @@ class MainWindow(QMainWindow): self._progress.setRange(0, 1) def _show_result(self, output_path): + # Sauvegarder l'etat actuel pour annulation + if self._current_file: + self._undo_stack.append(self._current_file) + self._redo_stack.clear() + if len(self._undo_stack) > 20: + self._undo_stack.pop(0) self._result_file = output_path self._current_file = output_path self._viewer.load_pdf(output_path) @@ -496,6 +524,97 @@ class MainWindow(QMainWindow): self._status_label.setText("Traitement termine") QTimer.singleShot(100, self._viewer.zoom_fit) + def _do_undo(self): + if not self._undo_stack: + return + self._redo_stack.append(self._current_file) + prev = self._undo_stack.pop() + self._current_file = prev + self._result_file = prev + self._viewer.load_pdf(prev) + self._page_panel.load_thumbnails(self._viewer) + self._status_label.setText("Annule") + QTimer.singleShot(100, self._viewer.zoom_fit) + + def _do_redo(self): + if not self._redo_stack: + return + self._undo_stack.append(self._current_file) + nxt = self._redo_stack.pop() + self._current_file = nxt + self._result_file = nxt + self._viewer.load_pdf(nxt) + self._page_panel.load_thumbnails(self._viewer) + self._status_label.setText("Refait") + QTimer.singleShot(100, self._viewer.zoom_fit) + + # ── Rotation ── + + def _on_page_rotate(self, page_index, degrees): + if not self._current_file: + return + from pypdf import PdfReader, PdfWriter + reader = PdfReader(self._current_file) + writer = PdfWriter() + for i, page in enumerate(reader.pages): + if i == page_index: + page = page.rotate(degrees) + writer.add_page(page) + output = self._make_temp() + with open(output, "wb") as f: + writer.write(f) + self._show_result(output) + + def _rotate_current(self, degrees): + if not self._require_file(): + return + self._on_page_rotate(self._viewer.current_page, degrees) + + def _do_reverse_pages(self): + if not self._require_file(): + return + from pypdf import PdfReader, PdfWriter + self._show_progress() + reader = PdfReader(self._current_file) + writer = PdfWriter() + for page in reversed(reader.pages): + writer.add_page(page) + output = self._make_temp() + with open(output, "wb") as f: + writer.write(f) + self._show_result(output) + self._status_label.setText("Ordre des pages inverse") + + def _do_unbooklet(self): + if not self._require_file(): + return + self._show_progress() + output = self._make_temp() + try: + unbooklet(self._current_file, output) + self._show_result(output) + self._status_label.setText("Delivretisation terminee") + except Exception as e: + self._hide_progress() + QMessageBox.critical(self, "Erreur", str(e)) + + def _do_trim_shift(self): + if not self._require_file(): + return + from pypdf import PdfReader + reader = PdfReader(self._current_file) + dlg = TrimShiftDialog(total_pages=len(reader.pages), parent=self) + if dlg.exec(): + self._show_progress() + settings = dlg.get_settings() + output = self._make_temp() + try: + trim_and_shift(self._current_file, output, **settings) + self._show_result(output) + except Exception as e: + self._hide_progress() + QMessageBox.critical(self, "Erreur", str(e)) + # ── Actions imposition ── def _do_booklet(self): @@ -789,14 +908,20 @@ class MainWindow(QMainWindow): return try: source = self._result_file or self._current_file + # Copier dans un fichier temp avec un nom lisible + import shutil + tmp_print = os.path.join(tempfile.gettempdir(), "copydev_print.pdf") + shutil.copy2(source, tmp_print) if sys.platform == "win32": - os.startfile(source, "print") + import subprocess + # Ouvrir avec le lecteur PDF par defaut (qui a un menu imprimer) + subprocess.Popen(["cmd", "/c", "start", "", tmp_print]) else: import subprocess - subprocess.Popen(["lp", source]) - self._status_label.setText("Impression lancee...") + subprocess.Popen(["xdg-open", tmp_print]) + self._status_label.setText("Fichier ouvert pour impression") except Exception as e: - QMessageBox.critical(self, "Erreur", f"Impossible d'imprimer :\n{e}") + QMessageBox.critical(self, "Erreur", f"Impossible d'ouvrir pour impression :\n{e}") def _do_crop_marks(self): if not self._require_file(): diff --git a/app/page_panel.py b/app/page_panel.py index 8d55bbd..f048323 100644 --- a/app/page_panel.py +++ b/app/page_panel.py @@ -9,10 +9,11 @@ from PyQt6.QtCore import Qt, pyqtSignal, QSize class PagePanel(QWidget): """Panneau de vignettes avec drag & drop, suppression, insertion.""" - page_selected = pyqtSignal(int) # page index cliquee - pages_reordered = pyqtSignal(list) # nouvelle liste d'indices - page_delete_requested = pyqtSignal(int) # demande suppression - page_insert_requested = pyqtSignal(int) # demande insertion page blanche apres + page_selected = pyqtSignal(int) + pages_reordered = pyqtSignal(list) + page_delete_requested = pyqtSignal(int) + page_insert_requested = pyqtSignal(int) + page_rotate_requested = pyqtSignal(int, int) # page_index, degrees (90, -90, 180) def __init__(self, parent=None): super().__init__(parent) @@ -106,6 +107,10 @@ class PagePanel(QWidget): QMenu::item:selected { background: #e0f4ff; color: #006fa6; } """) + act_rot_right = menu.addAction("Pivoter +90°") + act_rot_left = menu.addAction("Pivoter -90°") + act_rot_180 = menu.addAction("Pivoter 180°") + menu.addSeparator() act_insert = menu.addAction("Inserer une page blanche apres") act_delete = menu.addAction("Supprimer cette page") @@ -114,3 +119,9 @@ class PagePanel(QWidget): self.page_delete_requested.emit(row) elif action == act_insert: self.page_insert_requested.emit(row) + elif action == act_rot_right: + self.page_rotate_requested.emit(row, 90) + elif action == act_rot_left: + self.page_rotate_requested.emit(row, -90) + elif action == act_rot_180: + self.page_rotate_requested.emit(row, 180) diff --git a/app/updater.py b/app/updater.py index 802ea38..fa29604 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.6.0" +VERSION = "3.0.0" def get_remote_version(): diff --git a/version.txt b/version.txt index e70b452..4a36342 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.6.0 +3.0.0