commit e3dfd90933b22fb5c1cd69979141423b06fd1de7 Author: Jules Date: Fri Mar 20 10:54:02 2026 +0100 Initial commit — Copydev Imposing Tool Outil d'imposition PDF desktop (PyQt6) remplacant Quite Imposing Plus. 9 fonctions : livret, n-up, step & repeat, imposition manuelle, jointure pages, pages blanches, numerotation, masquage, fonds perdus. Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4adc4ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.pyo +dist/ +build/output/ +*.spec.bak +venv/ +.venv/ +*.egg-info/ diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/dialogs/__init__.py b/app/dialogs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/dialogs/bleeds_dialog.py b/app/dialogs/bleeds_dialog.py new file mode 100644 index 0000000..8973315 --- /dev/null +++ b/app/dialogs/bleeds_dialog.py @@ -0,0 +1,53 @@ +"""Dialogue Definition des fonds perdus.""" + +from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QGroupBox, QDoubleSpinBox, QDialogButtonBox) + + +class BleedsDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Fonds perdus (Bleeds)") + self.setMinimumWidth(350) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + layout.addWidget(QLabel( + "Definir la zone de fond perdu autour de chaque page.\n" + "Cette zone sera prise en compte par les fonctions N-Up et Repetition." + )) + + grp = QGroupBox("Fonds perdus (mm)") + gl = QVBoxLayout(grp) + + self.spins = {} + for label in ["Haut", "Bas", "Gauche", "Droite"]: + hl = QHBoxLayout() + hl.addWidget(QLabel(f"{label} :")) + spin = QDoubleSpinBox() + spin.setRange(0, 50) + spin.setValue(3) + spin.setSuffix(" mm") + hl.addWidget(spin) + hl.addStretch() + gl.addLayout(hl) + self.spins[label.lower()] = spin + + layout.addWidget(grp) + + 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 { + "bleed_top": self.spins["haut"].value(), + "bleed_bottom": self.spins["bas"].value(), + "bleed_left": self.spins["gauche"].value(), + "bleed_right": self.spins["droite"].value(), + } diff --git a/app/dialogs/booklet_dialog.py b/app/dialogs/booklet_dialog.py new file mode 100644 index 0000000..e224a26 --- /dev/null +++ b/app/dialogs/booklet_dialog.py @@ -0,0 +1,104 @@ +"""Dialogue Creer un livret.""" + +from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QComboBox, QGroupBox, QRadioButton, QSpinBox, + QDialogButtonBox, QDoubleSpinBox) +from app.engine.pdf_utils import PAGE_SIZES + + +class BookletDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Creer un livret") + self.setMinimumWidth(400) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # Type de reliure + grp_binding = QGroupBox("Type de reliure") + bl = QVBoxLayout(grp_binding) + self.rb_saddle = QRadioButton("Piqure a cheval (livret agrafe)") + self.rb_saddle.setChecked(True) + self.rb_perfect = QRadioButton("Dos carre colle (livre)") + self.rb_continuous = QRadioButton("Continu (pages sequentielles)") + self.rb_cut_stacks = QRadioButton("Cut stacks (couper et empiler)") + bl.addWidget(self.rb_saddle) + bl.addWidget(self.rb_perfect) + bl.addWidget(self.rb_continuous) + bl.addWidget(self.rb_cut_stacks) + layout.addWidget(grp_binding) + + # Taille signature (perfect bound) + sig_layout = QHBoxLayout() + sig_layout.addWidget(QLabel("Pages par cahier :")) + self.spin_signature = QSpinBox() + self.spin_signature.setRange(4, 128) + self.spin_signature.setValue(32) + self.spin_signature.setSingleStep(4) + self.spin_signature.setEnabled(False) + sig_layout.addWidget(self.spin_signature) + sig_layout.addStretch() + layout.addLayout(sig_layout) + + self.rb_perfect.toggled.connect(self.spin_signature.setEnabled) + + # Taille de feuille + grp_sheet = QGroupBox("Taille de la feuille") + sl = QHBoxLayout(grp_sheet) + sl.addWidget(QLabel("Format :")) + self.combo_sheet = QComboBox() + self.combo_sheet.addItem("Automatique (2x page)") + for name in PAGE_SIZES: + self.combo_sheet.addItem(name) + sl.addWidget(self.combo_sheet) + layout.addWidget(grp_sheet) + + # Alignement + grp_align = QGroupBox("Alignement") + al = QVBoxLayout(grp_align) + self.rb_center = QRadioButton("Centrer") + self.rb_center.setChecked(True) + self.rb_pull = QRadioButton("Vers le dos (centre)") + self.rb_bl = QRadioButton("Bas-gauche") + al.addWidget(self.rb_center) + al.addWidget(self.rb_pull) + al.addWidget(self.rb_bl) + layout.addWidget(grp_align) + + # Boutons OK/Annuler + 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): + """Retourner les parametres selectionnes.""" + if self.rb_saddle.isChecked(): + binding = "saddle_stitch" + elif self.rb_perfect.isChecked(): + binding = "perfect_bound" + elif self.rb_continuous.isChecked(): + binding = "continuous" + else: + binding = "cut_stacks" + + sheet_text = self.combo_sheet.currentText() + sheet_size = PAGE_SIZES.get(sheet_text, None) + + if self.rb_center.isChecked(): + alignment = "center" + elif self.rb_pull.isChecked(): + alignment = "pull_center" + else: + alignment = "bottom_left" + + return { + "binding": binding, + "sheet_size": sheet_size, + "signature_size": self.spin_signature.value(), + "alignment": alignment, + } diff --git a/app/dialogs/insert_blank_dialog.py b/app/dialogs/insert_blank_dialog.py new file mode 100644 index 0000000..14e5323 --- /dev/null +++ b/app/dialogs/insert_blank_dialog.py @@ -0,0 +1,82 @@ +"""Dialogue Inserer des pages blanches.""" + +from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QGroupBox, QRadioButton, QSpinBox, + QDialogButtonBox) + + +class InsertBlankDialog(QDialog): + def __init__(self, total_pages=1, parent=None): + super().__init__(parent) + self.setWindowTitle("Inserer des pages blanches") + self.setMinimumWidth(400) + self._total_pages = total_pages + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + grp = QGroupBox("Mode d'insertion") + gl = QVBoxLayout(grp) + + self.rb_after = QRadioButton("Apres la page :") + self.rb_after.setChecked(True) + after_layout = QHBoxLayout() + after_layout.addWidget(self.rb_after) + self.spin_pos = QSpinBox() + self.spin_pos.setRange(1, self._total_pages) + self.spin_pos.setValue(1) + after_layout.addWidget(self.spin_pos) + after_layout.addStretch() + gl.addLayout(after_layout) + + self.rb_before = QRadioButton("Avant la page selectionnee") + gl.addWidget(self.rb_before) + + every_layout = QHBoxLayout() + self.rb_every = QRadioButton("Toutes les") + every_layout.addWidget(self.rb_every) + self.spin_every = QSpinBox() + self.spin_every.setRange(1, 100) + self.spin_every.setValue(2) + self.spin_every.setEnabled(False) + every_layout.addWidget(self.spin_every) + every_layout.addWidget(QLabel("pages")) + every_layout.addStretch() + gl.addLayout(every_layout) + + pad_layout = QHBoxLayout() + self.rb_pad = QRadioButton("Completer au multiple de") + pad_layout.addWidget(self.rb_pad) + self.spin_multiple = QSpinBox() + self.spin_multiple.setRange(2, 32) + self.spin_multiple.setValue(4) + self.spin_multiple.setEnabled(False) + pad_layout.addWidget(self.spin_multiple) + pad_layout.addWidget(QLabel("pages")) + pad_layout.addStretch() + gl.addLayout(pad_layout) + + layout.addWidget(grp) + + # Activer/desactiver les spinbox + self.rb_every.toggled.connect(self.spin_every.setEnabled) + self.rb_pad.toggled.connect(self.spin_multiple.setEnabled) + + # Boutons + 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): + if self.rb_after.isChecked(): + return {"mode": "after", "position": self.spin_pos.value()} + elif self.rb_before.isChecked(): + return {"mode": "before", "position": self.spin_pos.value()} + elif self.rb_every.isChecked(): + return {"mode": "every_n", "every_n": self.spin_every.value()} + else: + return {"mode": "pad_to_multiple", "target_multiple": self.spin_multiple.value()} diff --git a/app/dialogs/join_pages_dialog.py b/app/dialogs/join_pages_dialog.py new file mode 100644 index 0000000..087ed25 --- /dev/null +++ b/app/dialogs/join_pages_dialog.py @@ -0,0 +1,38 @@ +"""Dialogue Joindre deux pages.""" + +from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QLabel, QGroupBox, + QRadioButton, QDialogButtonBox) + + +class JoinPagesDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Joindre deux pages") + self.setMinimumWidth(300) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + layout.addWidget(QLabel("Les pages seront jointes par paires (1+2, 3+4, ...).")) + + grp = QGroupBox("Direction") + gl = QVBoxLayout(grp) + self.rb_horizontal = QRadioButton("Cote a cote (horizontal)") + self.rb_horizontal.setChecked(True) + self.rb_vertical = QRadioButton("Dessus/dessous (vertical)") + gl.addWidget(self.rb_horizontal) + gl.addWidget(self.rb_vertical) + layout.addWidget(grp) + + 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 { + "direction": "horizontal" if self.rb_horizontal.isChecked() else "vertical" + } diff --git a/app/dialogs/manual_impose_dialog.py b/app/dialogs/manual_impose_dialog.py new file mode 100644 index 0000000..84e3d12 --- /dev/null +++ b/app/dialogs/manual_impose_dialog.py @@ -0,0 +1,122 @@ +"""Dialogue Imposition manuelle.""" + +from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QGroupBox, QComboBox, QSpinBox, QDoubleSpinBox, + QDialogButtonBox, QPushButton, QListWidget, + QCheckBox) +from app.engine.pdf_utils import PAGE_SIZES, mm_to_pt + + +class ManualImposeDialog(QDialog): + def __init__(self, total_pages=1, parent=None): + super().__init__(parent) + self.setWindowTitle("Imposition manuelle") + self.setMinimumWidth(500) + self._total_pages = total_pages + self._placements = [] + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # Taille feuille + grp_sheet = QGroupBox("Taille de la feuille cible") + sl = QHBoxLayout(grp_sheet) + sl.addWidget(QLabel("Format :")) + self.combo_sheet = QComboBox() + for name in PAGE_SIZES: + self.combo_sheet.addItem(name) + self.combo_sheet.setCurrentText("A3 Paysage") + sl.addWidget(self.combo_sheet) + layout.addWidget(grp_sheet) + + # Placement + grp_place = QGroupBox("Placer une page") + pl = QHBoxLayout(grp_place) + + pl.addWidget(QLabel("Page:")) + self.spin_page = QSpinBox() + self.spin_page.setRange(1, self._total_pages) + pl.addWidget(self.spin_page) + + pl.addWidget(QLabel("X:")) + self.spin_x = QDoubleSpinBox() + self.spin_x.setRange(0, 2000) + self.spin_x.setSuffix(" mm") + pl.addWidget(self.spin_x) + + pl.addWidget(QLabel("Y:")) + self.spin_y = QDoubleSpinBox() + self.spin_y.setRange(0, 2000) + self.spin_y.setSuffix(" mm") + pl.addWidget(self.spin_y) + + pl.addWidget(QLabel("Rot:")) + self.combo_rot = QComboBox() + self.combo_rot.addItems(["0", "90", "180", "270"]) + pl.addWidget(self.combo_rot) + + pl.addWidget(QLabel("Ech:")) + self.spin_scale = QDoubleSpinBox() + self.spin_scale.setRange(0.1, 10.0) + self.spin_scale.setValue(1.0) + self.spin_scale.setSingleStep(0.1) + pl.addWidget(self.spin_scale) + + layout.addWidget(grp_place) + + # Boutons + btn_layout = QHBoxLayout() + btn_add = QPushButton("Ajouter") + btn_add.clicked.connect(self._add_placement) + btn_layout.addWidget(btn_add) + btn_del = QPushButton("Supprimer") + btn_del.clicked.connect(self._del_placement) + btn_layout.addWidget(btn_del) + btn_layout.addStretch() + layout.addLayout(btn_layout) + + # Liste + self.list_placements = QListWidget() + layout.addWidget(self.list_placements) + + # Crop marks + self.chk_crop = QCheckBox("Reperes de coupe") + layout.addWidget(self.chk_crop) + + # Boutons OK/Annuler + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _add_placement(self): + p = { + "page": self.spin_page.value(), + "x": mm_to_pt(self.spin_x.value()), + "y": mm_to_pt(self.spin_y.value()), + "rotation": int(self.combo_rot.currentText()), + "scale": self.spin_scale.value(), + } + self._placements.append(p) + self.list_placements.addItem( + f"Page {p['page']} - X={self.spin_x.value():.1f} Y={self.spin_y.value():.1f} mm " + f"Rot={self.combo_rot.currentText()} Ech={self.spin_scale.value():.1f}" + ) + + def _del_placement(self): + row = self.list_placements.currentRow() + if row >= 0: + self.list_placements.takeItem(row) + self._placements.pop(row) + + def get_settings(self): + sheet_text = self.combo_sheet.currentText() + sheet_size = PAGE_SIZES[sheet_text] + return { + "sheet_size": sheet_size, + "placements": self._placements, + "crop_marks": self.chk_crop.isChecked(), + } diff --git a/app/dialogs/masking_dialog.py b/app/dialogs/masking_dialog.py new file mode 100644 index 0000000..fdb408f --- /dev/null +++ b/app/dialogs/masking_dialog.py @@ -0,0 +1,112 @@ +"""Dialogue Masquage (rectangles blancs).""" + +from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QGroupBox, QComboBox, QDoubleSpinBox, + QDialogButtonBox, QPushButton, QListWidget, + QListWidgetItem) +from app.engine.pdf_utils import mm_to_pt + + +class MaskingDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Masquage (ruban blanc)") + self.setMinimumWidth(450) + self._rectangles = [] + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # Definition rectangle + grp_rect = QGroupBox("Definir un rectangle de masquage (mm)") + rl = QHBoxLayout(grp_rect) + + rl.addWidget(QLabel("X:")) + self.spin_x = QDoubleSpinBox() + self.spin_x.setRange(0, 1000) + self.spin_x.setSuffix(" mm") + rl.addWidget(self.spin_x) + + rl.addWidget(QLabel("Y:")) + self.spin_y = QDoubleSpinBox() + self.spin_y.setRange(0, 1000) + self.spin_y.setSuffix(" mm") + rl.addWidget(self.spin_y) + + rl.addWidget(QLabel("L:")) + self.spin_w = QDoubleSpinBox() + self.spin_w.setRange(1, 1000) + self.spin_w.setValue(50) + self.spin_w.setSuffix(" mm") + rl.addWidget(self.spin_w) + + rl.addWidget(QLabel("H:")) + self.spin_h = QDoubleSpinBox() + self.spin_h.setRange(1, 1000) + self.spin_h.setValue(10) + self.spin_h.setSuffix(" mm") + rl.addWidget(self.spin_h) + + layout.addWidget(grp_rect) + + # Boutons ajouter/supprimer + btn_layout = QHBoxLayout() + btn_add = QPushButton("Ajouter") + btn_add.clicked.connect(self._add_rect) + btn_layout.addWidget(btn_add) + btn_del = QPushButton("Supprimer") + btn_del.clicked.connect(self._del_rect) + btn_layout.addWidget(btn_del) + btn_layout.addStretch() + layout.addLayout(btn_layout) + + # Liste des rectangles + self.list_rects = QListWidget() + layout.addWidget(self.list_rects) + + # Appliquer a + grp_apply = QGroupBox("Appliquer a") + al = QHBoxLayout(grp_apply) + self.combo_apply = QComboBox() + self.combo_apply.addItems(["Toutes les pages", "Pages paires", "Pages impaires"]) + al.addWidget(self.combo_apply) + layout.addWidget(grp_apply) + + # Boutons + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _add_rect(self): + rect = { + "x": mm_to_pt(self.spin_x.value()), + "y": mm_to_pt(self.spin_y.value()), + "width": mm_to_pt(self.spin_w.value()), + "height": mm_to_pt(self.spin_h.value()), + } + self._rectangles.append(rect) + self.list_rects.addItem( + f"X={self.spin_x.value():.1f} Y={self.spin_y.value():.1f} " + f"L={self.spin_w.value():.1f} H={self.spin_h.value():.1f} mm" + ) + + def _del_rect(self): + row = self.list_rects.currentRow() + if row >= 0: + self.list_rects.takeItem(row) + self._rectangles.pop(row) + + def get_settings(self): + apply_map = { + "Toutes les pages": "all", + "Pages paires": "even", + "Pages impaires": "odd" + } + return { + "rectangles": self._rectangles, + "apply_to": apply_map[self.combo_apply.currentText()], + } diff --git a/app/dialogs/nup_dialog.py b/app/dialogs/nup_dialog.py new file mode 100644 index 0000000..46ff421 --- /dev/null +++ b/app/dialogs/nup_dialog.py @@ -0,0 +1,131 @@ +"""Dialogue N pages par feuille.""" + +from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QComboBox, QGroupBox, QSpinBox, QCheckBox, + QDialogButtonBox, QDoubleSpinBox, QFileDialog) +from app.engine.pdf_utils import PAGE_SIZES, mm_to_pt + + +class NupDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("N pages par feuille") + self.setMinimumWidth(450) + self._bg_path = None + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # Grille + grp_grid = QGroupBox("Disposition") + gl = QHBoxLayout(grp_grid) + gl.addWidget(QLabel("Colonnes :")) + self.spin_cols = QSpinBox() + self.spin_cols.setRange(1, 10) + self.spin_cols.setValue(2) + gl.addWidget(self.spin_cols) + gl.addWidget(QLabel("Lignes :")) + self.spin_rows = QSpinBox() + self.spin_rows.setRange(1, 10) + self.spin_rows.setValue(2) + gl.addWidget(self.spin_rows) + layout.addWidget(grp_grid) + + # Taille feuille + grp_sheet = QGroupBox("Taille de la feuille") + sl = QHBoxLayout(grp_sheet) + sl.addWidget(QLabel("Format :")) + self.combo_sheet = QComboBox() + for name in PAGE_SIZES: + self.combo_sheet.addItem(name) + self.combo_sheet.setCurrentText("A4 Paysage") + sl.addWidget(self.combo_sheet) + layout.addWidget(grp_sheet) + + # Marges + grp_margins = QGroupBox("Marges (mm)") + ml = QHBoxLayout(grp_margins) + self.margins = {} + for label in ["Haut", "Bas", "Gauche", "Droite"]: + ml.addWidget(QLabel(f"{label}:")) + spin = QDoubleSpinBox() + spin.setRange(0, 100) + spin.setValue(5) + spin.setSuffix(" mm") + ml.addWidget(spin) + self.margins[label.lower()] = spin + layout.addWidget(grp_margins) + + # Gouttiere + grp_gutter = QGroupBox("Gouttiere (mm)") + gutl = QHBoxLayout(grp_gutter) + gutl.addWidget(QLabel("Horizontale :")) + self.spin_gutter_h = QDoubleSpinBox() + self.spin_gutter_h.setRange(0, 50) + self.spin_gutter_h.setValue(0) + self.spin_gutter_h.setSuffix(" mm") + gutl.addWidget(self.spin_gutter_h) + gutl.addWidget(QLabel("Verticale :")) + self.spin_gutter_v = QDoubleSpinBox() + self.spin_gutter_v.setRange(0, 50) + self.spin_gutter_v.setValue(0) + self.spin_gutter_v.setSuffix(" mm") + gutl.addWidget(self.spin_gutter_v) + layout.addWidget(grp_gutter) + + # Options + grp_opts = QGroupBox("Options") + ol = QVBoxLayout(grp_opts) + self.chk_fit = QCheckBox("Ajuster les pages a la cellule") + self.chk_fit.setChecked(True) + ol.addWidget(self.chk_fit) + self.chk_crop = QCheckBox("Reperes de coupe") + ol.addWidget(self.chk_crop) + + bg_layout = QHBoxLayout() + self.chk_bg = QCheckBox("Fond PDF :") + bg_layout.addWidget(self.chk_bg) + self.lbl_bg = QLabel("Aucun") + bg_layout.addWidget(self.lbl_bg) + from PyQt6.QtWidgets import QPushButton + btn_bg = QPushButton("Choisir...") + btn_bg.clicked.connect(self._choose_bg) + bg_layout.addWidget(btn_bg) + bg_layout.addStretch() + ol.addLayout(bg_layout) + + layout.addWidget(grp_opts) + + # Boutons + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _choose_bg(self): + path, _ = QFileDialog.getOpenFileName(self, "Choisir un fond PDF", "", "PDF (*.pdf)") + if path: + self._bg_path = path + self.lbl_bg.setText(path.split("/")[-1].split("\\")[-1]) + self.chk_bg.setChecked(True) + + def get_settings(self): + sheet_text = self.combo_sheet.currentText() + sheet_size = PAGE_SIZES[sheet_text] + return { + "cols": self.spin_cols.value(), + "rows": self.spin_rows.value(), + "sheet_size": sheet_size, + "margin_top": mm_to_pt(self.margins["haut"].value()), + "margin_bottom": mm_to_pt(self.margins["bas"].value()), + "margin_left": mm_to_pt(self.margins["gauche"].value()), + "margin_right": mm_to_pt(self.margins["droite"].value()), + "gutter_h": mm_to_pt(self.spin_gutter_h.value()), + "gutter_v": mm_to_pt(self.spin_gutter_v.value()), + "scale_mode": "fit" if self.chk_fit.isChecked() else "none", + "crop_marks": self.chk_crop.isChecked(), + "background_path": self._bg_path if self.chk_bg.isChecked() else None, + } diff --git a/app/dialogs/page_numbers_dialog.py b/app/dialogs/page_numbers_dialog.py new file mode 100644 index 0000000..3ac9500 --- /dev/null +++ b/app/dialogs/page_numbers_dialog.py @@ -0,0 +1,120 @@ +"""Dialogue Numerotation de pages.""" + +from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QGroupBox, QComboBox, QSpinBox, QLineEdit, + QDialogButtonBox, QDoubleSpinBox) +from reportlab.lib.units import mm + + +class PageNumbersDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Numerotation de pages") + self.setMinimumWidth(400) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # Position + grp_pos = QGroupBox("Position") + pl = QHBoxLayout(grp_pos) + pl.addWidget(QLabel("Emplacement :")) + self.combo_pos = QComboBox() + positions = [ + "haut-gauche", "haut-centre", "haut-droite", + "milieu-gauche", "milieu-centre", "milieu-droite", + "bas-gauche", "bas-centre", "bas-droite" + ] + self.combo_pos.addItems(positions) + self.combo_pos.setCurrentText("bas-centre") + pl.addWidget(self.combo_pos) + layout.addWidget(grp_pos) + + # Police + grp_font = QGroupBox("Police") + fl = QHBoxLayout(grp_font) + fl.addWidget(QLabel("Police :")) + self.combo_font = QComboBox() + self.combo_font.addItems(["Helvetica", "Times-Roman", "Courier"]) + fl.addWidget(self.combo_font) + fl.addWidget(QLabel("Taille :")) + self.spin_size = QSpinBox() + self.spin_size.setRange(4, 72) + self.spin_size.setValue(10) + fl.addWidget(self.spin_size) + layout.addWidget(grp_font) + + # Texte + grp_text = QGroupBox("Texte") + tl = QHBoxLayout(grp_text) + tl.addWidget(QLabel("Prefixe :")) + self.edit_prefix = QLineEdit() + self.edit_prefix.setPlaceholderText("ex: Page ") + tl.addWidget(self.edit_prefix) + tl.addWidget(QLabel("Suffixe :")) + self.edit_suffix = QLineEdit() + self.edit_suffix.setPlaceholderText("ex: / 24") + tl.addWidget(self.edit_suffix) + layout.addWidget(grp_text) + + # Numero de depart + start_layout = QHBoxLayout() + start_layout.addWidget(QLabel("Commencer a :")) + self.spin_start = QSpinBox() + self.spin_start.setRange(0, 9999) + self.spin_start.setValue(1) + start_layout.addWidget(self.spin_start) + start_layout.addStretch() + layout.addLayout(start_layout) + + # Marges + margin_layout = QHBoxLayout() + margin_layout.addWidget(QLabel("Marge X :")) + self.spin_mx = QDoubleSpinBox() + self.spin_mx.setRange(0, 100) + self.spin_mx.setValue(10) + self.spin_mx.setSuffix(" mm") + margin_layout.addWidget(self.spin_mx) + margin_layout.addWidget(QLabel("Marge Y :")) + self.spin_my = QDoubleSpinBox() + self.spin_my.setRange(0, 100) + self.spin_my.setValue(10) + self.spin_my.setSuffix(" mm") + margin_layout.addWidget(self.spin_my) + layout.addLayout(margin_layout) + + # Appliquer a + grp_apply = QGroupBox("Appliquer a") + al = QHBoxLayout(grp_apply) + self.combo_apply = QComboBox() + self.combo_apply.addItems(["Toutes les pages", "Pages paires", "Pages impaires"]) + al.addWidget(self.combo_apply) + layout.addWidget(grp_apply) + + # Boutons + 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): + apply_map = { + "Toutes les pages": "all", + "Pages paires": "even", + "Pages impaires": "odd" + } + return { + "position": self.combo_pos.currentText(), + "font_name": self.combo_font.currentText(), + "font_size": self.spin_size.value(), + "color": (0, 0, 0), + "prefix": self.edit_prefix.text(), + "suffix": self.edit_suffix.text(), + "start_number": self.spin_start.value(), + "margin_x": self.spin_mx.value() * mm, + "margin_y": self.spin_my.value() * mm, + "apply_to": apply_map[self.combo_apply.currentText()], + } diff --git a/app/dialogs/step_repeat_dialog.py b/app/dialogs/step_repeat_dialog.py new file mode 100644 index 0000000..569160b --- /dev/null +++ b/app/dialogs/step_repeat_dialog.py @@ -0,0 +1,95 @@ +"""Dialogue Repetition (Step & Repeat).""" + +from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QComboBox, QGroupBox, QSpinBox, QCheckBox, + QDialogButtonBox, QDoubleSpinBox) +from app.engine.pdf_utils import PAGE_SIZES, mm_to_pt + + +class StepRepeatDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Repetition (Step & Repeat)") + self.setMinimumWidth(400) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # Taille feuille + grp_sheet = QGroupBox("Taille de la feuille") + sl = QHBoxLayout(grp_sheet) + sl.addWidget(QLabel("Format :")) + self.combo_sheet = QComboBox() + for name in PAGE_SIZES: + self.combo_sheet.addItem(name) + self.combo_sheet.setCurrentText("A3 Paysage") + sl.addWidget(self.combo_sheet) + layout.addWidget(grp_sheet) + + # Nombre de copies + grp_copies = QGroupBox("Nombre de copies") + cl = QVBoxLayout(grp_copies) + self.chk_auto = QCheckBox("Automatique (remplir la feuille)") + self.chk_auto.setChecked(True) + cl.addWidget(self.chk_auto) + + manual = QHBoxLayout() + manual.addWidget(QLabel("Horizontal :")) + self.spin_h = QSpinBox() + self.spin_h.setRange(1, 20) + self.spin_h.setValue(2) + self.spin_h.setEnabled(False) + manual.addWidget(self.spin_h) + manual.addWidget(QLabel("Vertical :")) + self.spin_v = QSpinBox() + self.spin_v.setRange(1, 20) + self.spin_v.setValue(2) + self.spin_v.setEnabled(False) + manual.addWidget(self.spin_v) + cl.addLayout(manual) + layout.addWidget(grp_copies) + + self.chk_auto.toggled.connect(lambda checked: self.spin_h.setEnabled(not checked)) + self.chk_auto.toggled.connect(lambda checked: self.spin_v.setEnabled(not checked)) + + # Espacement + grp_spacing = QGroupBox("Espacement (mm)") + spl = QHBoxLayout(grp_spacing) + spl.addWidget(QLabel("Horizontal :")) + self.spin_sp_h = QDoubleSpinBox() + self.spin_sp_h.setRange(0, 50) + self.spin_sp_h.setValue(0) + self.spin_sp_h.setSuffix(" mm") + spl.addWidget(self.spin_sp_h) + spl.addWidget(QLabel("Vertical :")) + self.spin_sp_v = QDoubleSpinBox() + self.spin_sp_v.setRange(0, 50) + self.spin_sp_v.setValue(0) + self.spin_sp_v.setSuffix(" mm") + spl.addWidget(self.spin_sp_v) + layout.addWidget(grp_spacing) + + # Options + self.chk_crop = QCheckBox("Reperes de coupe") + layout.addWidget(self.chk_crop) + + # Boutons + 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): + sheet_text = self.combo_sheet.currentText() + sheet_size = PAGE_SIZES[sheet_text] + return { + "sheet_size": sheet_size, + "copies_h": None if self.chk_auto.isChecked() else self.spin_h.value(), + "copies_v": None if self.chk_auto.isChecked() else self.spin_v.value(), + "spacing_h": mm_to_pt(self.spin_sp_h.value()), + "spacing_v": mm_to_pt(self.spin_sp_v.value()), + "crop_marks": self.chk_crop.isChecked(), + } diff --git a/app/engine/__init__.py b/app/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/engine/bleeds.py b/app/engine/bleeds.py new file mode 100644 index 0000000..7333363 --- /dev/null +++ b/app/engine/bleeds.py @@ -0,0 +1,36 @@ +"""Moteur Definition des fonds perdus (bleeds).""" + +from pypdf import PdfReader, PdfWriter +from .pdf_utils import get_page_size, mm_to_pt + + +def define_bleeds(input_path, output_path, bleed_top=0, bleed_bottom=0, + bleed_left=0, bleed_right=0): + """Definir les zones de fond perdu en etendant le TrimBox/BleedBox. + + Les valeurs sont en millimetres. + """ + bt = mm_to_pt(bleed_top) + bb = mm_to_pt(bleed_bottom) + bl = mm_to_pt(bleed_left) + br = mm_to_pt(bleed_right) + + reader = PdfReader(input_path) + writer = PdfWriter() + + for page in reader.pages: + w, h = get_page_size(page) + + # Le TrimBox definit la taille finale apres coupe + page.trimbox.lower_left = (bl, bb) + page.trimbox.upper_right = (w - br, h - bt) + + # Le BleedBox inclut le fond perdu + page.bleedbox.lower_left = (0, 0) + page.bleedbox.upper_right = (w, h) + + writer.add_page(page) + + with open(output_path, "wb") as f: + writer.write(f) + return output_path diff --git a/app/engine/booklet.py b/app/engine/booklet.py new file mode 100644 index 0000000..fca3787 --- /dev/null +++ b/app/engine/booklet.py @@ -0,0 +1,196 @@ +"""Moteur de creation de livrets.""" + +from pypdf import PdfReader, PdfWriter, PageObject, Transformation +from .pdf_utils import get_page_size, create_blank_page, pad_to_multiple +import io +import math + + +def create_booklet(input_path, output_path, binding="saddle_stitch", sheet_size=None, + signature_size=32, alignment="center"): + """Creer un livret a partir d'un PDF. + + binding: "continuous", "saddle_stitch", "perfect_bound", "cut_stacks" + sheet_size: (largeur, hauteur) en points, ou None pour auto (2x page) + signature_size: nombre de pages par cahier (perfect_bound uniquement) + alignment: "bottom_left", "center", "pull_center" + """ + reader = PdfReader(input_path) + + if binding == "continuous": + return _booklet_continuous(reader, output_path, sheet_size, alignment) + elif binding == "saddle_stitch": + return _booklet_saddle_stitch(reader, output_path, sheet_size, alignment) + elif binding == "perfect_bound": + return _booklet_perfect_bound(reader, output_path, sheet_size, signature_size, alignment) + elif binding == "cut_stacks": + return _booklet_cut_stacks(reader, output_path, sheet_size, alignment) + + +def _get_sheet_size(reader, sheet_size): + """Determiner la taille de la feuille.""" + page_w, page_h = get_page_size(reader.pages[0]) + if sheet_size: + return sheet_size + # Auto: feuille = 2x largeur de page + return (page_w * 2, page_h) + + +def _place_two_pages(left_page, right_page, sheet_w, sheet_h, page_w, page_h, alignment): + """Placer deux pages cote a cote sur une feuille.""" + sheet = PageObject.create_blank_page(width=sheet_w, height=sheet_h) + + half_w = sheet_w / 2 + + if alignment == "center": + left_x = (half_w - page_w) / 2 + right_x = half_w + (half_w - page_w) / 2 + y = (sheet_h - page_h) / 2 + elif alignment == "pull_center": + left_x = half_w - page_w + right_x = half_w + y = (sheet_h - page_h) / 2 + else: # bottom_left + left_x = 0 + right_x = half_w + y = 0 + + if left_page: + left_copy = PageObject.create_blank_page(width=sheet_w, height=sheet_h) + left_copy.merge_page(left_page) + left_copy.add_transformation(Transformation().translate(left_x, y)) + sheet.merge_page(left_copy) + + if right_page: + right_copy = PageObject.create_blank_page(width=sheet_w, height=sheet_h) + right_copy.merge_page(right_page) + right_copy.add_transformation(Transformation().translate(right_x, y)) + sheet.merge_page(right_copy) + + return sheet + + +def _booklet_continuous(reader, output_path, sheet_size, alignment): + """Mode continu: pages 1+2, 3+4, etc.""" + reader = pad_to_multiple(reader, 2) + pages = reader.pages + page_w, page_h = get_page_size(pages[0]) + sheet_w, sheet_h = _get_sheet_size(reader, sheet_size) + + writer = PdfWriter() + for i in range(0, len(pages), 2): + left = pages[i] + right = pages[i + 1] if i + 1 < len(pages) else None + sheet = _place_two_pages(left, right, sheet_w, sheet_h, page_w, page_h, alignment) + writer.add_page(sheet) + + with open(output_path, "wb") as f: + writer.write(f) + return output_path + + +def _saddle_stitch_order(num_pages): + """Calculer l'ordre des pages pour piqure a cheval. + + Pour N pages (multiple de 4): + Feuille 1 recto: page N, page 1 + Feuille 1 verso: page 2, page N-1 + Feuille 2 recto: page N-2, page 3 + etc. + """ + pairs = [] + n = num_pages + for i in range(n // 4): + # Recto + pairs.append((n - 1 - 2 * i, 2 * i)) + # Verso + pairs.append((2 * i + 1, n - 2 - 2 * i)) + return pairs + + +def _booklet_saddle_stitch(reader, output_path, sheet_size, alignment): + """Piqure a cheval.""" + reader = pad_to_multiple(reader, 4) + pages = reader.pages + num_pages = len(pages) + page_w, page_h = get_page_size(pages[0]) + sheet_w, sheet_h = _get_sheet_size(reader, sheet_size) + + pairs = _saddle_stitch_order(num_pages) + writer = PdfWriter() + + for left_idx, right_idx in pairs: + left = pages[left_idx] if left_idx < num_pages else None + right = pages[right_idx] if right_idx < num_pages else None + sheet = _place_two_pages(left, right, sheet_w, sheet_h, page_w, page_h, alignment) + writer.add_page(sheet) + + with open(output_path, "wb") as f: + writer.write(f) + return output_path + + +def _booklet_perfect_bound(reader, output_path, sheet_size, signature_size, alignment): + """Dos carre colle: plusieurs cahiers de signature_size pages.""" + reader = pad_to_multiple(reader, 4) + pages = reader.pages + num_pages = len(pages) + page_w, page_h = get_page_size(pages[0]) + sheet_w, sheet_h = _get_sheet_size(reader, sheet_size) + + writer = PdfWriter() + + # Decouper en signatures + for sig_start in range(0, num_pages, signature_size): + sig_end = min(sig_start + signature_size, num_pages) + sig_pages = list(range(sig_start, sig_end)) + + # Padding de la signature + while len(sig_pages) % 4 != 0: + sig_pages.append(None) + + n = len(sig_pages) + for i in range(n // 4): + # Recto + left_idx = sig_pages[n - 1 - 2 * i] + right_idx = sig_pages[2 * i] + left = pages[left_idx] if left_idx is not None and left_idx < num_pages else None + right = pages[right_idx] if right_idx is not None and right_idx < num_pages else None + sheet = _place_two_pages(left, right, sheet_w, sheet_h, page_w, page_h, alignment) + writer.add_page(sheet) + + # Verso + left_idx = sig_pages[2 * i + 1] + right_idx = sig_pages[n - 2 - 2 * i] + left = pages[left_idx] if left_idx is not None and left_idx < num_pages else None + right = pages[right_idx] if right_idx is not None and right_idx < num_pages else None + sheet = _place_two_pages(left, right, sheet_w, sheet_h, page_w, page_h, alignment) + writer.add_page(sheet) + + with open(output_path, "wb") as f: + writer.write(f) + return output_path + + +def _booklet_cut_stacks(reader, output_path, sheet_size, alignment): + """Cut stacks: imprimer, couper en deux, empiler.""" + reader = pad_to_multiple(reader, 4) + pages = reader.pages + num_pages = len(pages) + page_w, page_h = get_page_size(pages[0]) + sheet_w, sheet_h = _get_sheet_size(reader, sheet_size) + + writer = PdfWriter() + half = num_pages // 2 + + for i in range(half): + top_idx = i + bottom_idx = half + i + left = pages[top_idx] if top_idx < num_pages else None + right = pages[bottom_idx] if bottom_idx < num_pages else None + sheet = _place_two_pages(left, right, sheet_w, sheet_h, page_w, page_h, alignment) + writer.add_page(sheet) + + with open(output_path, "wb") as f: + writer.write(f) + return output_path diff --git a/app/engine/insert_blank.py b/app/engine/insert_blank.py new file mode 100644 index 0000000..22e1b37 --- /dev/null +++ b/app/engine/insert_blank.py @@ -0,0 +1,58 @@ +"""Moteur Insertion de pages blanches.""" + +from pypdf import PdfReader, PdfWriter +from .pdf_utils import get_page_size + + +def insert_blank_pages(input_path, output_path, mode="after", position=None, + every_n=None, target_multiple=None, page_size=None): + """Inserer des pages blanches. + + mode: "before", "after", "every_n", "pad_to_multiple" + position: numero de page (1-based) pour before/after + every_n: inserer apres chaque N pages + target_multiple: arrondir au multiple de N + page_size: (w, h) en points ou None pour meme taille que page adjacente + """ + reader = PdfReader(input_path) + writer = PdfWriter() + + if mode == "pad_to_multiple" and target_multiple: + for page in reader.pages: + writer.add_page(page) + num = len(reader.pages) + remainder = num % target_multiple + if remainder != 0: + to_add = target_multiple - remainder + last_w, last_h = get_page_size(reader.pages[-1]) + w, h = page_size if page_size else (last_w, last_h) + for _ in range(to_add): + writer.add_blank_page(w, h) + + elif mode == "every_n" and every_n: + for i, page in enumerate(reader.pages): + writer.add_page(page) + if (i + 1) % every_n == 0 and i + 1 < len(reader.pages): + w, h = get_page_size(page) + bw, bh = page_size if page_size else (w, h) + writer.add_blank_page(bw, bh) + + elif mode in ("before", "after") and position: + idx = position - 1 # 0-based + for i, page in enumerate(reader.pages): + if mode == "before" and i == idx: + w, h = get_page_size(page) + bw, bh = page_size if page_size else (w, h) + writer.add_blank_page(bw, bh) + writer.add_page(page) + if mode == "after" and i == idx: + w, h = get_page_size(page) + bw, bh = page_size if page_size else (w, h) + writer.add_blank_page(bw, bh) + else: + for page in reader.pages: + writer.add_page(page) + + with open(output_path, "wb") as f: + writer.write(f) + return output_path diff --git a/app/engine/join_pages.py b/app/engine/join_pages.py new file mode 100644 index 0000000..bdac802 --- /dev/null +++ b/app/engine/join_pages.py @@ -0,0 +1,60 @@ +"""Moteur Joindre deux pages.""" + +from pypdf import PdfReader, PdfWriter, PageObject, Transformation +from .pdf_utils import get_page_size + + +def join_pages(input_path, output_path, direction="horizontal"): + """Joindre les pages par paires. + + direction: "horizontal" (cote a cote) ou "vertical" (dessus/dessous) + """ + reader = PdfReader(input_path) + pages = reader.pages + writer = PdfWriter() + + for i in range(0, len(pages), 2): + page1 = pages[i] + page2 = pages[i + 1] if i + 1 < len(pages) else None + + w1, h1 = get_page_size(page1) + + if page2: + w2, h2 = get_page_size(page2) + else: + w2, h2 = w1, h1 + + if direction == "horizontal": + new_w = w1 + w2 + new_h = max(h1, h2) + sheet = PageObject.create_blank_page(width=new_w, height=new_h) + + overlay1 = PageObject.create_blank_page(width=new_w, height=new_h) + overlay1.merge_page(page1) + sheet.merge_page(overlay1) + + if page2: + overlay2 = PageObject.create_blank_page(width=new_w, height=new_h) + overlay2.merge_page(page2) + overlay2.add_transformation(Transformation().translate(w1, 0)) + sheet.merge_page(overlay2) + else: # vertical + new_w = max(w1, w2) + new_h = h1 + h2 + sheet = PageObject.create_blank_page(width=new_w, height=new_h) + + overlay1 = PageObject.create_blank_page(width=new_w, height=new_h) + overlay1.merge_page(page1) + overlay1.add_transformation(Transformation().translate(0, h2)) + sheet.merge_page(overlay1) + + if page2: + overlay2 = PageObject.create_blank_page(width=new_w, height=new_h) + overlay2.merge_page(page2) + sheet.merge_page(overlay2) + + writer.add_page(sheet) + + with open(output_path, "wb") as f: + writer.write(f) + return output_path diff --git a/app/engine/manual_impose.py b/app/engine/manual_impose.py new file mode 100644 index 0000000..af00271 --- /dev/null +++ b/app/engine/manual_impose.py @@ -0,0 +1,68 @@ +"""Moteur Imposition manuelle.""" + +from pypdf import PdfReader, PdfWriter, PageObject, Transformation +from .pdf_utils import get_page_size, create_crop_marks_page + + +def manual_impose(input_path, output_path, sheet_size, placements, crop_marks=False): + """Imposition manuelle : placer des pages a des positions precises. + + sheet_size: (largeur, hauteur) en points + placements: liste de dicts: + {"page": int (1-based), "x": float, "y": float, "scale": float, "rotation": int} + """ + reader = PdfReader(input_path) + pages = reader.pages + sheet_w, sheet_h = sheet_size + + writer = PdfWriter() + sheet = PageObject.create_blank_page(width=sheet_w, height=sheet_h) + + mark_positions = [] + + for p in placements: + page_idx = p["page"] - 1 + if page_idx < 0 or page_idx >= len(pages): + continue + + page = pages[page_idx] + page_w, page_h = get_page_size(page) + x = p.get("x", 0) + y = p.get("y", 0) + scale = p.get("scale", 1.0) + rotation = p.get("rotation", 0) + + overlay = PageObject.create_blank_page(width=sheet_w, height=sheet_h) + overlay.merge_page(page) + + transform = Transformation() + if rotation: + # Rotation autour du centre de la page + cx = page_w / 2 + cy = page_h / 2 + transform = transform.translate(-cx, -cy) + transform = transform.rotate(rotation) + transform = transform.translate(cx, cy) + transform = transform.scale(scale, scale).translate(x, y) + overlay.add_transformation(transform) + sheet.merge_page(overlay) + + if crop_marks: + scaled_w = page_w * scale + scaled_h = page_h * scale + mark_positions.extend([ + (x, y), + (x + scaled_w, y), + (x, y + scaled_h), + (x + scaled_w, y + scaled_h), + ]) + + if crop_marks and mark_positions: + marks = create_crop_marks_page(sheet_w, sheet_h, mark_positions) + sheet.merge_page(marks) + + writer.add_page(sheet) + + with open(output_path, "wb") as f: + writer.write(f) + return output_path diff --git a/app/engine/masking.py b/app/engine/masking.py new file mode 100644 index 0000000..c698e65 --- /dev/null +++ b/app/engine/masking.py @@ -0,0 +1,53 @@ +"""Moteur Masquage (Masking Tape) — rectangles blancs.""" + +from pypdf import PdfReader, PdfWriter +from reportlab.pdfgen import canvas +from .pdf_utils import get_page_size +import io + + +def apply_masking(input_path, output_path, rectangles, apply_to="all"): + """Appliquer des rectangles blancs de masquage. + + rectangles: liste de dicts {"x": pt, "y": pt, "width": pt, "height": pt} + apply_to: "all", "even", "odd", ou un numero de page (1-based) + """ + reader = PdfReader(input_path) + writer = PdfWriter() + + for i, page in enumerate(reader.pages): + page_num = i + 1 + + should_apply = False + if apply_to == "all": + should_apply = True + elif apply_to == "even": + should_apply = page_num % 2 == 0 + elif apply_to == "odd": + should_apply = page_num % 2 != 0 + elif isinstance(apply_to, int): + should_apply = page_num == apply_to + + if should_apply and rectangles: + w, h = get_page_size(page) + packet = io.BytesIO() + c = canvas.Canvas(packet, pagesize=(w, h)) + c.setFillColorRGB(1, 1, 1) + c.setStrokeColorRGB(1, 1, 1) + + for rect in rectangles: + c.rect(rect["x"], rect["y"], rect["width"], rect["height"], + fill=1, stroke=0) + + c.showPage() + c.save() + packet.seek(0) + + overlay_reader = PdfReader(packet) + page.merge_page(overlay_reader.pages[0]) + + writer.add_page(page) + + with open(output_path, "wb") as f: + writer.write(f) + return output_path diff --git a/app/engine/nup.py b/app/engine/nup.py new file mode 100644 index 0000000..170261e --- /dev/null +++ b/app/engine/nup.py @@ -0,0 +1,102 @@ +"""Moteur N-Up : placer plusieurs pages par feuille.""" + +from pypdf import PdfReader, PdfWriter, PageObject, Transformation +from .pdf_utils import get_page_size, create_blank_page, mm_to_pt, smart_crop_marks +import io + + +def nup_pages(input_path, output_path, cols=2, rows=2, sheet_size=(841.89, 595.28), + margin_top=0, margin_bottom=0, margin_left=0, margin_right=0, + gutter_h=0, gutter_v=0, scale_mode="fit", crop_marks=False, + background_path=None): + """Placer plusieurs pages sur chaque feuille. + + cols, rows: grille + sheet_size: (largeur, hauteur) en points + margins: en points + gutter_h, gutter_v: espacement horizontal/vertical entre pages + scale_mode: "fit" (ajuster) ou "none" (taille reelle) + crop_marks: ajouter des reperes de coupe + background_path: chemin vers un PDF de fond + """ + reader = PdfReader(input_path) + pages = reader.pages + sheet_w, sheet_h = sheet_size + + # Zone utile + usable_w = sheet_w - margin_left - margin_right - gutter_h * (cols - 1) + usable_h = sheet_h - margin_top - margin_bottom - gutter_v * (rows - 1) + + cell_w = usable_w / cols + cell_h = usable_h / rows + + # Fond + bg_page = None + if background_path: + bg_reader = PdfReader(background_path) + if bg_reader.pages: + bg_page = bg_reader.pages[0] + + writer = PdfWriter() + pages_per_sheet = cols * rows + + for sheet_idx in range(0, len(pages), pages_per_sheet): + sheet = PageObject.create_blank_page(width=sheet_w, height=sheet_h) + + if bg_page: + sheet.merge_page(bg_page) + + grid_positions = [] + + for slot in range(pages_per_sheet): + page_idx = sheet_idx + slot + if page_idx >= len(pages): + break + + page = pages[page_idx] + page_w, page_h = get_page_size(page) + + col = slot % cols + row = slot // cols + grid_positions.append((col, row)) + + # Position de la cellule + x = margin_left + col * (cell_w + gutter_h) + y = sheet_h - margin_top - (row + 1) * cell_h - row * gutter_v + + # Calcul du scale + if scale_mode == "fit": + scale_x = cell_w / page_w + scale_y = cell_h / page_h + scale = min(scale_x, scale_y) + else: + scale = 1.0 + + # Centrage dans la cellule + scaled_w = page_w * scale + scaled_h = page_h * scale + offset_x = x + (cell_w - scaled_w) / 2 + offset_y = y + (cell_h - scaled_h) / 2 + + # Placer la page + overlay = PageObject.create_blank_page(width=sheet_w, height=sheet_h) + overlay.merge_page(page) + overlay.add_transformation( + Transformation().scale(scale, scale).translate(offset_x, offset_y) + ) + sheet.merge_page(overlay) + + # Reperes de coupe + if crop_marks and grid_positions: + marks = smart_crop_marks( + sheet_w, sheet_h, grid_positions, + cell_w + gutter_h, cell_h + gutter_v, + margin_left, margin_top + ) + sheet.merge_page(marks) + + writer.add_page(sheet) + + with open(output_path, "wb") as f: + writer.write(f) + return output_path diff --git a/app/engine/page_numbers.py b/app/engine/page_numbers.py new file mode 100644 index 0000000..a40d789 --- /dev/null +++ b/app/engine/page_numbers.py @@ -0,0 +1,89 @@ +"""Moteur Numerotation de pages.""" + +from pypdf import PdfReader, PdfWriter +from reportlab.pdfgen import canvas +from reportlab.lib.units import mm +from .pdf_utils import get_page_size +import io + + +POSITIONS = { + "haut-gauche": ("left", "top"), + "haut-centre": ("center", "top"), + "haut-droite": ("right", "top"), + "milieu-gauche": ("left", "middle"), + "milieu-centre": ("center", "middle"), + "milieu-droite": ("right", "middle"), + "bas-gauche": ("left", "bottom"), + "bas-centre": ("center", "bottom"), + "bas-droite": ("right", "bottom"), +} + + +def add_page_numbers(input_path, output_path, position="bas-centre", + font_name="Helvetica", font_size=10, color=(0, 0, 0), + prefix="", suffix="", start_number=1, + margin_x=10*mm, margin_y=10*mm, + apply_to="all"): + """Ajouter des numeros de page. + + position: cle de POSITIONS + apply_to: "all", "even", "odd" + """ + reader = PdfReader(input_path) + writer = PdfWriter() + + h_align, v_align = POSITIONS.get(position, ("center", "bottom")) + total_pages = len(reader.pages) + + for i, page in enumerate(reader.pages): + page_num = start_number + i + + # Filtre pair/impair + if apply_to == "even" and page_num % 2 != 0: + writer.add_page(page) + continue + if apply_to == "odd" and page_num % 2 == 0: + writer.add_page(page) + continue + + w, h = get_page_size(page) + + # Generer le texte + text = f"{prefix}{page_num}{suffix}" + + # Creer overlay avec le numero + packet = io.BytesIO() + c = canvas.Canvas(packet, pagesize=(w, h)) + c.setFont(font_name, font_size) + c.setFillColorRGB(*color) + + # Calculer la position + text_width = c.stringWidth(text, font_name, font_size) + + if h_align == "left": + x = margin_x + elif h_align == "right": + x = w - margin_x - text_width + else: + x = (w - text_width) / 2 + + if v_align == "top": + y = h - margin_y - font_size + elif v_align == "middle": + y = (h - font_size) / 2 + else: + y = margin_y + + c.drawString(x, y, text) + c.showPage() + c.save() + + packet.seek(0) + overlay_reader = PdfReader(packet) + page.merge_page(overlay_reader.pages[0]) + writer.add_page(page) + + with open(output_path, "wb") as f: + writer.write(f) + return output_path diff --git a/app/engine/pdf_utils.py b/app/engine/pdf_utils.py new file mode 100644 index 0000000..28f8148 --- /dev/null +++ b/app/engine/pdf_utils.py @@ -0,0 +1,143 @@ +"""Utilitaires PDF communs pour Copydev Imposing Tool.""" + +from pypdf import PdfReader, PdfWriter, PageObject, Transformation +from reportlab.pdfgen import canvas +from reportlab.lib.units import mm, inch +from reportlab.lib.pagesizes import A4, A3, letter +import io +import math + +# Tailles de pages predefinies (largeur, hauteur en points) +PAGE_SIZES = { + "A4 Portrait": (595.28, 841.89), + "A4 Paysage": (841.89, 595.28), + "A3 Portrait": (841.89, 1190.55), + "A3 Paysage": (1190.55, 841.89), + "Letter Portrait": (612, 792), + "Letter Paysage": (792, 612), + "Legal Portrait": (612, 1008), + "Legal Paysage": (1008, 612), +} + +# Conversion +PT_TO_MM = 25.4 / 72 +MM_TO_PT = 72 / 25.4 + + +def mm_to_pt(val_mm): + """Convertir millimetres en points.""" + return val_mm * MM_TO_PT + + +def pt_to_mm(val_pt): + """Convertir points en millimetres.""" + return val_pt * PT_TO_MM + + +def get_page_size(page): + """Obtenir la taille d'une page (largeur, hauteur) en points.""" + box = page.mediabox + w = float(box.width) + h = float(box.height) + return w, h + + +def create_blank_page(width, height): + """Creer une page blanche avec les dimensions donnees (en points).""" + packet = io.BytesIO() + c = canvas.Canvas(packet, pagesize=(width, height)) + c.showPage() + c.save() + packet.seek(0) + reader = PdfReader(packet) + return reader.pages[0] + + +def merge_page_onto(base_page, overlay_page, x=0, y=0, scale=1.0, rotation=0): + """Fusionner une page overlay sur une page de base a une position donnee.""" + if rotation: + overlay_page = overlay_page.rotate(rotation) + + transform = Transformation().scale(scale, scale).translate(x, y) + overlay_page.add_transformation(transform) + base_page.merge_page(overlay_page) + return base_page + + +def create_crop_marks_page(width, height, positions, mark_length=14.17, mark_offset=7.09, line_width=0.25): + """Creer une page avec des reperes de coupe aux positions donnees. + + positions: liste de tuples (x, y) ou placer les reperes + mark_length: longueur des traits (defaut 5mm = 14.17pt) + mark_offset: distance du bord de la page (defaut 2.5mm = 7.09pt) + """ + packet = io.BytesIO() + c = canvas.Canvas(packet, pagesize=(width, height)) + c.setLineWidth(line_width) + c.setStrokeColorRGB(0, 0, 0) + + for x, y in positions: + # Trait horizontal gauche + c.line(x - mark_offset - mark_length, y, x - mark_offset, y) + # Trait horizontal droit + c.line(x + mark_offset, y, x + mark_offset + mark_length, y) + # Trait vertical bas + c.line(x, y - mark_offset - mark_length, x, y - mark_offset) + # Trait vertical haut + c.line(x, y + mark_offset, x, y + mark_offset + mark_length) + + c.showPage() + c.save() + packet.seek(0) + reader = PdfReader(packet) + return reader.pages[0] + + +def smart_crop_marks(sheet_width, sheet_height, grid_positions, cell_width, cell_height, + margin_left=0, margin_top=0, mark_length=14.17, mark_offset=7.09): + """Generer des reperes de coupe intelligents pour une grille n-up. + + Evite les chevauchements avec le contenu des pages. + """ + positions = set() + + for col, row in grid_positions: + x = margin_left + col * cell_width + y = sheet_height - margin_top - row * cell_height + + # 4 coins de chaque cellule + corners = [ + (x, y), + (x + cell_width, y), + (x, y - cell_height), + (x + cell_width, y - cell_height), + ] + for cx, cy in corners: + positions.add((cx, cy)) + + return create_crop_marks_page(sheet_width, sheet_height, list(positions), + mark_length, mark_offset) + + +def pad_to_multiple(reader, multiple=4): + """Ajouter des pages blanches pour atteindre un multiple donne.""" + num_pages = len(reader.pages) + remainder = num_pages % multiple + if remainder == 0: + return reader + + pages_to_add = multiple - remainder + writer = PdfWriter() + for page in reader.pages: + writer.add_page(page) + + last_page = reader.pages[-1] + w, h = get_page_size(last_page) + + for _ in range(pages_to_add): + writer.add_blank_page(w, h) + + output = io.BytesIO() + writer.write(output) + output.seek(0) + return PdfReader(output) diff --git a/app/engine/step_repeat.py b/app/engine/step_repeat.py new file mode 100644 index 0000000..68cd576 --- /dev/null +++ b/app/engine/step_repeat.py @@ -0,0 +1,66 @@ +"""Moteur Step & Repeat : dupliquer une page plusieurs fois sur une feuille.""" + +from pypdf import PdfReader, PdfWriter, PageObject, Transformation +from .pdf_utils import get_page_size, smart_crop_marks +import math + + +def step_and_repeat(input_path, output_path, sheet_size=(841.89, 595.28), + copies_h=None, copies_v=None, spacing_h=0, spacing_v=0, + crop_marks=False): + """Dupliquer chaque page sur une feuille. + + copies_h, copies_v: nombre de copies horizontales/verticales (None = auto) + spacing_h, spacing_v: espacement en points + """ + reader = PdfReader(input_path) + sheet_w, sheet_h = sheet_size + writer = PdfWriter() + + for page in reader.pages: + page_w, page_h = get_page_size(page) + + # Auto-calcul du nombre de copies + if copies_h is None: + copies_h_calc = max(1, int((sheet_w + spacing_h) / (page_w + spacing_h))) + else: + copies_h_calc = copies_h + + if copies_v is None: + copies_v_calc = max(1, int((sheet_h + spacing_v) / (page_h + spacing_v))) + else: + copies_v_calc = copies_v + + # Centrage sur la feuille + total_w = copies_h_calc * page_w + (copies_h_calc - 1) * spacing_h + total_h = copies_v_calc * page_h + (copies_v_calc - 1) * spacing_v + start_x = (sheet_w - total_w) / 2 + start_y = (sheet_h - total_h) / 2 + + sheet = PageObject.create_blank_page(width=sheet_w, height=sheet_h) + grid_positions = [] + + for row in range(copies_v_calc): + for col in range(copies_h_calc): + x = start_x + col * (page_w + spacing_h) + y = start_y + (copies_v_calc - 1 - row) * (page_h + spacing_v) + + overlay = PageObject.create_blank_page(width=sheet_w, height=sheet_h) + overlay.merge_page(page) + overlay.add_transformation(Transformation().translate(x, y)) + sheet.merge_page(overlay) + grid_positions.append((col, row)) + + if crop_marks and grid_positions: + marks = smart_crop_marks( + sheet_w, sheet_h, grid_positions, + page_w + spacing_h, page_h + spacing_v, + start_x, sheet_h - start_y - total_h + ) + sheet.merge_page(marks) + + writer.add_page(sheet) + + 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 new file mode 100644 index 0000000..5ebb5ff --- /dev/null +++ b/app/main_window.py @@ -0,0 +1,341 @@ +"""Fenetre principale de Copydev Imposing Tool.""" + +import os +import tempfile +from PyQt6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QPushButton, QLabel, QFileDialog, QMessageBox, + QGroupBox, QStatusBar, QMenuBar, QSplitter, + QProgressBar) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QAction + +from app.pdf_viewer import PdfViewer +from app.dialogs.booklet_dialog import BookletDialog +from app.dialogs.nup_dialog import NupDialog +from app.dialogs.step_repeat_dialog import StepRepeatDialog +from app.dialogs.manual_impose_dialog import ManualImposeDialog +from app.dialogs.join_pages_dialog import JoinPagesDialog +from app.dialogs.insert_blank_dialog import InsertBlankDialog +from app.dialogs.page_numbers_dialog import PageNumbersDialog +from app.dialogs.masking_dialog import MaskingDialog +from app.dialogs.bleeds_dialog import BleedsDialog + +from app.engine.booklet import create_booklet +from app.engine.nup import nup_pages +from app.engine.step_repeat import step_and_repeat +from app.engine.manual_impose import manual_impose +from app.engine.join_pages import join_pages +from app.engine.insert_blank import insert_blank_pages +from app.engine.page_numbers import add_page_numbers +from app.engine.masking import apply_masking +from app.engine.bleeds import define_bleeds + + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("Copydev Imposing Tool") + self.setMinimumSize(1000, 700) + self._current_file = None + self._result_file = None + self._setup_menu() + self._setup_ui() + self._setup_statusbar() + + def _setup_menu(self): + menubar = self.menuBar() + + # Menu Fichier + file_menu = menubar.addMenu("Fichier") + + open_act = QAction("Ouvrir PDF...", self) + open_act.setShortcut("Ctrl+O") + open_act.triggered.connect(self._open_file) + file_menu.addAction(open_act) + + save_act = QAction("Enregistrer le resultat...", self) + save_act.setShortcut("Ctrl+S") + save_act.triggered.connect(self._save_result) + file_menu.addAction(save_act) + + file_menu.addSeparator() + + quit_act = QAction("Quitter", self) + quit_act.setShortcut("Ctrl+Q") + quit_act.triggered.connect(self.close) + file_menu.addAction(quit_act) + + # Menu Aide + help_menu = menubar.addMenu("Aide") + about_act = QAction("A propos...", self) + about_act.triggered.connect(self._show_about) + help_menu.addAction(about_act) + + def _setup_ui(self): + central = QWidget() + self.setCentralWidget(central) + main_layout = QHBoxLayout(central) + + # === Panneau gauche : boutons === + left_panel = QWidget() + left_panel.setFixedWidth(200) + left_layout = QVBoxLayout(left_panel) + left_layout.setContentsMargins(5, 5, 5, 5) + + # Ouvrir + btn_open = QPushButton("Ouvrir PDF...") + btn_open.clicked.connect(self._open_file) + left_layout.addWidget(btn_open) + + btn_save = QPushButton("Enregistrer resultat...") + btn_save.clicked.connect(self._save_result) + left_layout.addWidget(btn_save) + + left_layout.addSpacing(10) + + # Imposition + grp_impose = QGroupBox("Imposition") + il = QVBoxLayout(grp_impose) + + btn_booklet = QPushButton("Creer un livret") + btn_booklet.clicked.connect(self._do_booklet) + il.addWidget(btn_booklet) + + btn_nup = QPushButton("N pages par feuille") + btn_nup.clicked.connect(self._do_nup) + il.addWidget(btn_nup) + + btn_repeat = QPushButton("Repetition") + btn_repeat.clicked.connect(self._do_step_repeat) + il.addWidget(btn_repeat) + + btn_manual = QPushButton("Imposition manuelle") + btn_manual.clicked.connect(self._do_manual_impose) + il.addWidget(btn_manual) + + btn_join = QPushButton("Joindre deux pages") + btn_join.clicked.connect(self._do_join_pages) + il.addWidget(btn_join) + + left_layout.addWidget(grp_impose) + + # Pages + grp_pages = QGroupBox("Pages") + pl = QVBoxLayout(grp_pages) + + btn_blank = QPushButton("Pages blanches") + btn_blank.clicked.connect(self._do_insert_blank) + pl.addWidget(btn_blank) + + left_layout.addWidget(grp_pages) + + # Annotations + grp_annot = QGroupBox("Annotations") + al = QVBoxLayout(grp_annot) + + btn_numbers = QPushButton("Numerotation") + btn_numbers.clicked.connect(self._do_page_numbers) + al.addWidget(btn_numbers) + + btn_mask = QPushButton("Masquage") + btn_mask.clicked.connect(self._do_masking) + al.addWidget(btn_mask) + + btn_bleeds = QPushButton("Fonds perdus") + btn_bleeds.clicked.connect(self._do_bleeds) + al.addWidget(btn_bleeds) + + left_layout.addWidget(grp_annot) + left_layout.addStretch() + + main_layout.addWidget(left_panel) + + # === Zone centrale : viewer PDF === + self._viewer = PdfViewer() + self._viewer.page_changed.connect(self._on_page_changed) + main_layout.addWidget(self._viewer, 1) + + def _setup_statusbar(self): + self._statusbar = QStatusBar() + self.setStatusBar(self._statusbar) + self._status_label = QLabel("Pret") + self._statusbar.addWidget(self._status_label, 1) + self._file_label = QLabel("") + self._statusbar.addPermanentWidget(self._file_label) + + def _on_page_changed(self, page, total): + pass + + def _open_file(self): + path, _ = QFileDialog.getOpenFileName( + self, "Ouvrir un fichier PDF", "", "PDF (*.pdf)" + ) + if path: + self._current_file = path + self._result_file = None + self._viewer.load_pdf(path) + self._file_label.setText(os.path.basename(path)) + self._status_label.setText(f"Fichier charge : {os.path.basename(path)}") + + def _save_result(self): + source = self._result_file or self._current_file + if not source: + QMessageBox.warning(self, "Erreur", "Aucun fichier a enregistrer.") + return + + path, _ = QFileDialog.getSaveFileName( + self, "Enregistrer le resultat", "", "PDF (*.pdf)" + ) + if path: + if not path.lower().endswith(".pdf"): + path += ".pdf" + import shutil + shutil.copy2(source, path) + self._status_label.setText(f"Enregistre : {os.path.basename(path)}") + + def _require_file(self): + if not self._current_file: + QMessageBox.warning(self, "Erreur", "Veuillez d'abord ouvrir un fichier PDF.") + return False + return True + + def _make_temp(self): + fd, path = tempfile.mkstemp(suffix=".pdf") + os.close(fd) + return path + + def _show_result(self, output_path): + self._result_file = output_path + self._viewer.load_pdf(output_path) + self._status_label.setText("Traitement termine") + + def _do_booklet(self): + if not self._require_file(): + return + dlg = BookletDialog(self) + if dlg.exec(): + settings = dlg.get_settings() + output = self._make_temp() + try: + create_booklet(self._current_file, output, **settings) + self._show_result(output) + except Exception as e: + QMessageBox.critical(self, "Erreur", str(e)) + + def _do_nup(self): + if not self._require_file(): + return + dlg = NupDialog(self) + if dlg.exec(): + settings = dlg.get_settings() + output = self._make_temp() + try: + nup_pages(self._current_file, output, **settings) + self._show_result(output) + except Exception as e: + QMessageBox.critical(self, "Erreur", str(e)) + + def _do_step_repeat(self): + if not self._require_file(): + return + dlg = StepRepeatDialog(self) + if dlg.exec(): + settings = dlg.get_settings() + output = self._make_temp() + try: + step_and_repeat(self._current_file, output, **settings) + self._show_result(output) + except Exception as e: + QMessageBox.critical(self, "Erreur", str(e)) + + def _do_manual_impose(self): + if not self._require_file(): + return + from pypdf import PdfReader + reader = PdfReader(self._current_file) + dlg = ManualImposeDialog(total_pages=len(reader.pages), parent=self) + if dlg.exec(): + settings = dlg.get_settings() + output = self._make_temp() + try: + manual_impose(self._current_file, output, **settings) + self._show_result(output) + except Exception as e: + QMessageBox.critical(self, "Erreur", str(e)) + + def _do_join_pages(self): + if not self._require_file(): + return + dlg = JoinPagesDialog(self) + if dlg.exec(): + settings = dlg.get_settings() + output = self._make_temp() + try: + join_pages(self._current_file, output, **settings) + self._show_result(output) + except Exception as e: + QMessageBox.critical(self, "Erreur", str(e)) + + def _do_insert_blank(self): + if not self._require_file(): + return + from pypdf import PdfReader + reader = PdfReader(self._current_file) + dlg = InsertBlankDialog(total_pages=len(reader.pages), parent=self) + if dlg.exec(): + settings = dlg.get_settings() + output = self._make_temp() + try: + insert_blank_pages(self._current_file, output, **settings) + self._show_result(output) + except Exception as e: + QMessageBox.critical(self, "Erreur", str(e)) + + def _do_page_numbers(self): + if not self._require_file(): + return + dlg = PageNumbersDialog(self) + if dlg.exec(): + settings = dlg.get_settings() + output = self._make_temp() + try: + add_page_numbers(self._current_file, output, **settings) + self._show_result(output) + except Exception as e: + QMessageBox.critical(self, "Erreur", str(e)) + + def _do_masking(self): + if not self._require_file(): + return + dlg = MaskingDialog(self) + if dlg.exec(): + settings = dlg.get_settings() + output = self._make_temp() + try: + apply_masking(self._current_file, output, **settings) + self._show_result(output) + except Exception as e: + QMessageBox.critical(self, "Erreur", str(e)) + + def _do_bleeds(self): + if not self._require_file(): + return + dlg = BleedsDialog(self) + if dlg.exec(): + settings = dlg.get_settings() + output = self._make_temp() + try: + define_bleeds(self._current_file, output, **settings) + self._show_result(output) + except Exception as e: + QMessageBox.critical(self, "Erreur", str(e)) + + def _show_about(self): + QMessageBox.about( + self, + "A propos", + "Copydev Imposing Tool\n\n" + "Outil d'imposition PDF\n" + "Remplace Quite Imposing Plus\n\n" + "Copydev 2026" + ) diff --git a/app/pdf_viewer.py b/app/pdf_viewer.py new file mode 100644 index 0000000..6fb1f6d --- /dev/null +++ b/app/pdf_viewer.py @@ -0,0 +1,178 @@ +"""Widget de visualisation PDF avec pypdfium2.""" + +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QSpinBox, QScrollArea, QSizePolicy) +from PyQt6.QtGui import QImage, QPixmap +from PyQt6.QtCore import Qt, pyqtSignal +import pypdfium2 as pdfium + + +class PdfViewer(QWidget): + """Widget d'apercu PDF avec navigation et zoom.""" + + page_changed = pyqtSignal(int, int) # page_num, total_pages + + def __init__(self, parent=None): + super().__init__(parent) + self._pdf = None + self._current_page = 0 + self._total_pages = 0 + self._zoom = 1.0 + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # Zone d'affichage avec scroll + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self._image_label = QLabel() + self._image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._image_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) + self._scroll.setWidget(self._image_label) + layout.addWidget(self._scroll, 1) + + # Barre de navigation + nav = QHBoxLayout() + + self._btn_prev = QPushButton("<") + self._btn_prev.setFixedWidth(40) + self._btn_prev.clicked.connect(self._prev_page) + nav.addWidget(self._btn_prev) + + self._page_spin = QSpinBox() + self._page_spin.setMinimum(1) + self._page_spin.setMaximum(1) + self._page_spin.valueChanged.connect(self._go_to_page) + nav.addWidget(self._page_spin) + + self._label_total = QLabel("/ 0") + nav.addWidget(self._label_total) + + self._btn_next = QPushButton(">") + self._btn_next.setFixedWidth(40) + self._btn_next.clicked.connect(self._next_page) + nav.addWidget(self._btn_next) + + nav.addSpacing(20) + + self._btn_zoom_out = QPushButton("-") + self._btn_zoom_out.setFixedWidth(30) + self._btn_zoom_out.clicked.connect(self._zoom_out) + nav.addWidget(self._btn_zoom_out) + + self._label_zoom = QLabel("100%") + self._label_zoom.setFixedWidth(45) + self._label_zoom.setAlignment(Qt.AlignmentFlag.AlignCenter) + nav.addWidget(self._label_zoom) + + self._btn_zoom_in = QPushButton("+") + self._btn_zoom_in.setFixedWidth(30) + self._btn_zoom_in.clicked.connect(self._zoom_in) + nav.addWidget(self._btn_zoom_in) + + self._btn_fit = QPushButton("Ajuster") + self._btn_fit.clicked.connect(self._zoom_fit) + nav.addWidget(self._btn_fit) + + nav.addStretch() + layout.addLayout(nav) + + def load_pdf(self, path): + """Charger un fichier PDF.""" + try: + self._pdf = pdfium.PdfDocument(path) + self._total_pages = len(self._pdf) + self._current_page = 0 + self._page_spin.setMaximum(self._total_pages) + self._page_spin.setValue(1) + self._label_total.setText(f"/ {self._total_pages}") + self._render_page() + except Exception as e: + self._image_label.setText(f"Erreur: {e}") + + def load_pdf_bytes(self, data): + """Charger un PDF depuis des bytes.""" + try: + self._pdf = pdfium.PdfDocument(data) + self._total_pages = len(self._pdf) + self._current_page = 0 + self._page_spin.setMaximum(self._total_pages) + self._page_spin.setValue(1) + self._label_total.setText(f"/ {self._total_pages}") + self._render_page() + except Exception as e: + self._image_label.setText(f"Erreur: {e}") + + def _render_page(self): + """Afficher la page courante.""" + if not self._pdf or self._current_page >= self._total_pages: + return + + page = self._pdf[self._current_page] + # Rendu a 150 DPI * zoom + scale = 150 / 72 * self._zoom + bitmap = page.render(scale=scale) + pil_image = bitmap.to_pil() + + # Convertir PIL -> QImage -> QPixmap + data = pil_image.tobytes("raw", "RGBA") + qimage = QImage(data, pil_image.width, pil_image.height, QImage.Format.Format_RGBA8888) + pixmap = QPixmap.fromImage(qimage) + + self._image_label.setPixmap(pixmap) + self._image_label.adjustSize() + self._label_zoom.setText(f"{int(self._zoom * 100)}%") + self.page_changed.emit(self._current_page + 1, self._total_pages) + + def _prev_page(self): + if self._current_page > 0: + self._current_page -= 1 + self._page_spin.setValue(self._current_page + 1) + + def _next_page(self): + if self._current_page < self._total_pages - 1: + self._current_page += 1 + self._page_spin.setValue(self._current_page + 1) + + def _go_to_page(self, value): + page = value - 1 + if 0 <= page < self._total_pages and page != self._current_page: + self._current_page = page + 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.25) + self._render_page() + + def _zoom_fit(self): + """Ajuster le zoom pour que la page tienne dans la zone visible.""" + 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 + scale_x = vw / pw + scale_y = vh / ph + self._zoom = min(scale_x, scale_y) * (72 / 150) + self._render_page() + + def refresh(self): + """Rafraichir l'affichage.""" + self._render_page() + + @property + def current_page(self): + return self._current_page + + @property + def total_pages(self): + return self._total_pages diff --git a/app/presets/__init__.py b/app/presets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..b8f247f --- /dev/null +++ b/build.bat @@ -0,0 +1,22 @@ +@echo off +REM Script de build Windows pour Copydev Imposing Tool +REM Prerequis : Python 3.12+, pip install -r requirements.txt + +echo Installation des dependances... +pip install -r requirements.txt + +echo. +echo Construction de l'executable... +pyinstaller --onefile --noconsole --name CopydevImposingTool main.py ^ + --hidden-import pypdf ^ + --hidden-import reportlab ^ + --hidden-import reportlab.pdfgen ^ + --hidden-import reportlab.lib ^ + --hidden-import reportlab.lib.pagesizes ^ + --hidden-import reportlab.lib.units ^ + --hidden-import pypdfium2 ^ + --hidden-import pypdfium2._helpers + +echo. +echo Termine ! L'executable est dans le dossier dist/ +pause diff --git a/build/build.spec b/build/build.spec new file mode 100644 index 0000000..81b08c1 --- /dev/null +++ b/build/build.spec @@ -0,0 +1,56 @@ +# -*- mode: python ; coding: utf-8 -*- +# PyInstaller spec pour Copydev Imposing Tool + +import sys +import os + +block_cipher = None + +a = Analysis( + ['../main.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[ + 'pypdf', + 'reportlab', + 'reportlab.pdfgen', + 'reportlab.lib', + 'reportlab.lib.pagesizes', + 'reportlab.lib.units', + 'pypdfium2', + 'pypdfium2._helpers', + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='CopydevImposingTool', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/main.py b/main.py new file mode 100644 index 0000000..37efa81 --- /dev/null +++ b/main.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Copydev Imposing Tool — Point d'entree.""" + +import sys +import os + +# Ajouter le repertoire racine au path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from PyQt6.QtWidgets import QApplication +from app.main_window import MainWindow + + +def main(): + app = QApplication(sys.argv) + app.setApplicationName("Copydev Imposing Tool") + app.setOrganizationName("Copydev") + + window = MainWindow() + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e8b65f5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +PyQt6>=6.6 +pypdf>=4.0 +reportlab>=4.0 +pypdfium2>=4.0 +PyInstaller>=6.0