From 202834a7942f97b60d270660446296530edb9531 Mon Sep 17 00:00:00 2001 From: Jules Date: Sun, 29 Mar 2026 22:12:54 +0200 Subject: [PATCH] =?UTF-8?q?V3.6.0=20=E2=80=94=20Demonter=20pages,=20masqua?= =?UTF-8?q?ge=20visuel,=20regles,=20bleeds,=20corrections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Demonter les pages : couper en 2 sans reordonner (sous Delivretiser) - Masquage visuel : selection au curseur sur l'apercu + plage de pages - Regles horizontale/verticale en mm (menu Affichage) - Fonds perdus : filigrane rouge pour visualiser les bleeds - Reperes de coupe : marques a l'exterieur (page agrandie), 2 traits/coin - Numerotation : couleur configurable via selecteur - Decalage : Trim & Shift renomme en francais, creep -> chasse - Recadrages multiples cumulatifs (plages differentes) - Fix spinbox format personnalise (fleches aleatoires) - Fix combo livret : option Automatique preservee Co-Authored-By: Claude Opus 4.6 (1M context) --- app/dialogs/custom_size_dialog.py | 6 + app/dialogs/format_combo.py | 19 ++- app/dialogs/masking_dialog.py | 244 ++++++++++++++++++++++++----- app/dialogs/page_numbers_dialog.py | 29 +++- app/dialogs/trim_shift_dialog.py | 10 +- app/engine/crop.py | 48 ++++-- app/engine/crop_marks.py | 75 ++++++--- app/engine/disassemble.py | 39 +++++ app/engine/masking.py | 5 +- app/main_window.py | 102 ++++++++++-- app/pdf_viewer.py | 207 +++++++++++++++++++++++- app/updater.py | 2 +- version.txt | 2 +- 13 files changed, 675 insertions(+), 113 deletions(-) create mode 100644 app/engine/disassemble.py diff --git a/app/dialogs/custom_size_dialog.py b/app/dialogs/custom_size_dialog.py index 47226fa..1501d9c 100644 --- a/app/dialogs/custom_size_dialog.py +++ b/app/dialogs/custom_size_dialog.py @@ -64,12 +64,18 @@ class CustomSizeDialog(QDialog): self.spin_w.setRange(10, 5000) self.spin_w.setValue(210) self.spin_w.setSuffix(" mm") + self.spin_w.setDecimals(1) + self.spin_w.setSingleStep(1.0) + self.spin_w.setMinimumWidth(100) gl.addWidget(self.spin_w) gl.addWidget(QLabel("Hauteur :")) self.spin_h = QDoubleSpinBox() self.spin_h.setRange(10, 5000) self.spin_h.setValue(297) self.spin_h.setSuffix(" mm") + self.spin_h.setDecimals(1) + self.spin_h.setSingleStep(1.0) + self.spin_h.setMinimumWidth(100) gl.addWidget(self.spin_h) layout.addWidget(grp) diff --git a/app/dialogs/format_combo.py b/app/dialogs/format_combo.py index 2499861..54600c6 100644 --- a/app/dialogs/format_combo.py +++ b/app/dialogs/format_combo.py @@ -12,10 +12,27 @@ ADD_LABEL = "Ajouter un format..." def populate_format_combo(combo, default="A4 Paysage"): - """Remplir un combo avec les formats standard + custom + options.""" + """Remplir un combo avec les formats standard + custom + options. + + Si default n'est pas un format standard, il est conserve en tete du combo. + """ combo.blockSignals(True) + + # Sauvegarder l'item par defaut s'il n'est pas un format standard + keep_first = None + if default and default not in PAGE_SIZES: + # Verifier s'il est deja dans le combo avec des data + idx = combo.findText(default) + if idx >= 0: + keep_first = (default, combo.itemData(idx)) + combo.clear() + # Re-ajouter l'item special en tete si necessaire + if keep_first: + combo.addItem(keep_first[0], keep_first[1]) + combo.insertSeparator(combo.count()) + # Formats standard for name in PAGE_SIZES: combo.addItem(name, PAGE_SIZES[name]) diff --git a/app/dialogs/masking_dialog.py b/app/dialogs/masking_dialog.py index fdb408f..90b9730 100644 --- a/app/dialogs/masking_dialog.py +++ b/app/dialogs/masking_dialog.py @@ -1,61 +1,109 @@ -"""Dialogue Masquage (rectangles blancs).""" +"""Dialogue Masquage (rectangles blancs) — avec selection visuelle.""" from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, QGroupBox, QComboBox, QDoubleSpinBox, QDialogButtonBox, QPushButton, QListWidget, - QListWidgetItem) -from app.engine.pdf_utils import mm_to_pt + QLineEdit, QRadioButton) +from PyQt6.QtCore import Qt +from app.engine.pdf_utils import mm_to_pt, pt_to_mm class MaskingDialog(QDialog): - def __init__(self, parent=None): + def __init__(self, page_width_pt=595, page_height_pt=842, total_pages=1, + current_page=0, viewer=None, parent=None): super().__init__(parent) self.setWindowTitle("Masquage (ruban blanc)") - self.setMinimumWidth(450) + self.setFixedWidth(380) + self.setModal(False) + self.setWindowFlags(self.windowFlags() | Qt.WindowType.Tool) + self._page_w = page_width_pt + self._page_h = page_height_pt + self._total_pages = total_pages + self._current_page = current_page + self._viewer = viewer 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) + # Info + pw_mm = pt_to_mm(self._page_w) + ph_mm = pt_to_mm(self._page_h) + info = QLabel(f"Page : {pw_mm:.1f} x {ph_mm:.1f} mm") + info.setStyleSheet("color: #888; font-size: 11px;") + layout.addWidget(info) - rl.addWidget(QLabel("X:")) + # Bouton selection visuelle + if self._viewer: + sel_layout = QHBoxLayout() + self.btn_select = QPushButton("Selectionner la zone sur l'apercu") + self.btn_select.setCheckable(True) + self.btn_select.setStyleSheet(""" + QPushButton { background: #00b4d8; color: white; padding: 6px 14px; + border-radius: 6px; font-weight: bold; font-size: 11px; } + QPushButton:checked { background: #e74c3c; } + QPushButton:hover { background: #0096b7; } + QPushButton:checked:hover { background: #c0392b; } + """) + self.btn_select.toggled.connect(self._toggle_select_mode) + sel_layout.addWidget(self.btn_select) + sel_layout.addStretch() + layout.addLayout(sel_layout) + + self._viewer.selection_changed.connect(self._on_selection_changed) + + # Coordonnees manuelles + grp_rect = QGroupBox("Zone de masquage (mm)") + rl = QVBoxLayout(grp_rect) + + row1 = QHBoxLayout() + row1.addWidget(QLabel("X :")) self.spin_x = QDoubleSpinBox() - self.spin_x.setRange(0, 1000) + self.spin_x.setRange(0, 2000) self.spin_x.setSuffix(" mm") - rl.addWidget(self.spin_x) - - rl.addWidget(QLabel("Y:")) + row1.addWidget(self.spin_x) + row1.addWidget(QLabel("Y :")) self.spin_y = QDoubleSpinBox() - self.spin_y.setRange(0, 1000) + self.spin_y.setRange(0, 2000) self.spin_y.setSuffix(" mm") - rl.addWidget(self.spin_y) + row1.addWidget(self.spin_y) + rl.addLayout(row1) - rl.addWidget(QLabel("L:")) + row2 = QHBoxLayout() + row2.addWidget(QLabel("Largeur :")) self.spin_w = QDoubleSpinBox() - self.spin_w.setRange(1, 1000) + self.spin_w.setRange(1, 2000) self.spin_w.setValue(50) self.spin_w.setSuffix(" mm") - rl.addWidget(self.spin_w) - - rl.addWidget(QLabel("H:")) + row2.addWidget(self.spin_w) + row2.addWidget(QLabel("Hauteur :")) self.spin_h = QDoubleSpinBox() - self.spin_h.setRange(1, 1000) + self.spin_h.setRange(1, 2000) self.spin_h.setValue(10) self.spin_h.setSuffix(" mm") - rl.addWidget(self.spin_h) + row2.addWidget(self.spin_h) + rl.addLayout(row2) layout.addWidget(grp_rect) - # Boutons ajouter/supprimer + # Bouton ajouter btn_layout = QHBoxLayout() - btn_add = QPushButton("Ajouter") - btn_add.clicked.connect(self._add_rect) - btn_layout.addWidget(btn_add) + self.btn_add = QPushButton("Ajouter ce masque") + self.btn_add.setStyleSheet(""" + QPushButton { background: #2ecc71; color: white; padding: 6px 14px; + border-radius: 6px; font-weight: bold; } + QPushButton:hover { background: #27ae60; } + """) + self.btn_add.clicked.connect(self._add_rect) + btn_layout.addWidget(self.btn_add) + btn_del = QPushButton("Supprimer") + btn_del.setStyleSheet(""" + QPushButton { background: #e8e8e8; color: #555; padding: 6px 14px; + border-radius: 6px; border: 1px solid #ccc; } + QPushButton:hover { background: #ddd; } + """) btn_del.clicked.connect(self._del_rect) btn_layout.addWidget(btn_del) btn_layout.addStretch() @@ -63,15 +111,31 @@ class MaskingDialog(QDialog): # Liste des rectangles self.list_rects = QListWidget() + self.list_rects.setMaximumHeight(100) 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) + 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( @@ -81,32 +145,128 @@ class MaskingDialog(QDialog): buttons.rejected.connect(self.reject) layout.addWidget(buttons) + def _toggle_select_mode(self, checked): + if self._viewer: + self._viewer.set_select_mode(checked) + if checked: + self.btn_select.setText("Mode selection actif — dessiner la zone") + else: + self.btn_select.setText("Selectionner la zone sur l'apercu") + + def _on_selection_changed(self, left, top, right, bottom): + """Recoit les ratios de selection du viewer.""" + pw_mm = pt_to_mm(self._page_w) + ph_mm = pt_to_mm(self._page_h) + + x_mm = left * pw_mm + y_mm = top * ph_mm + w_mm = (right - left) * pw_mm + h_mm = (bottom - top) * ph_mm + + if w_mm > 0 and h_mm > 0: + self.spin_x.blockSignals(True) + self.spin_y.blockSignals(True) + self.spin_w.blockSignals(True) + self.spin_h.blockSignals(True) + self.spin_x.setValue(x_mm) + self.spin_y.setValue(y_mm) + self.spin_w.setValue(w_mm) + self.spin_h.setValue(h_mm) + self.spin_x.blockSignals(False) + self.spin_y.blockSignals(False) + self.spin_w.blockSignals(False) + self.spin_h.blockSignals(False) + def _add_rect(self): + x_mm = self.spin_x.value() + y_mm = self.spin_y.value() + w_mm = self.spin_w.value() + h_mm = self.spin_h.value() + + # Convertir Y : dans le viewer le Y part du haut, dans le PDF du bas + page_h_mm = pt_to_mm(self._page_h) + pdf_y_mm = page_h_mm - y_mm - h_mm + 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()), + "x": mm_to_pt(x_mm), + "y": mm_to_pt(pdf_y_mm), + "width": mm_to_pt(w_mm), + "height": mm_to_pt(h_mm), } 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" + f"X={x_mm:.1f} Y={y_mm:.1f} L={w_mm:.1f} H={h_mm:.1f} mm" ) + # Nettoyer la selection pour en dessiner une autre + if self._viewer: + self._viewer.clear_selection() + def _del_rect(self): row = self.list_rects.currentRow() if row >= 0: self.list_rects.takeItem(row) self._rectangles.pop(row) + def _parse_range(self): + 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 closeEvent(self, event): + if self._viewer: + self._viewer.set_select_mode(False) + self._viewer.clear_selection() + super().closeEvent(event) + + def reject(self): + if self._viewer: + self._viewer.set_select_mode(False) + self._viewer.clear_selection() + super().reject() + def get_settings(self): - apply_map = { - "Toutes les pages": "all", - "Pages paires": "even", - "Pages impaires": "odd" - } + if self._viewer: + self._viewer.set_select_mode(False) + self._viewer.clear_selection() + + # Determiner apply_to + if self.rb_current.isChecked(): + apply_to = self._current_page + 1 # 1-based pour le moteur + elif self.rb_all.isChecked(): + apply_to = "all" + elif self.rb_even.isChecked(): + apply_to = "even" + elif self.rb_odd.isChecked(): + apply_to = "odd" + else: + # Plage personnalisee + pages = self._parse_range() + if pages: + apply_to = pages # liste d'indices 0-based + else: + apply_to = "all" + return { "rectangles": self._rectangles, - "apply_to": apply_map[self.combo_apply.currentText()], + "apply_to": apply_to, } diff --git a/app/dialogs/page_numbers_dialog.py b/app/dialogs/page_numbers_dialog.py index 3ac9500..5f8fee8 100644 --- a/app/dialogs/page_numbers_dialog.py +++ b/app/dialogs/page_numbers_dialog.py @@ -2,7 +2,9 @@ from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, QGroupBox, QComboBox, QSpinBox, QLineEdit, - QDialogButtonBox, QDoubleSpinBox) + QDialogButtonBox, QDoubleSpinBox, QPushButton, + QColorDialog) +from PyQt6.QtGui import QColor from reportlab.lib.units import mm @@ -11,6 +13,7 @@ class PageNumbersDialog(QDialog): super().__init__(parent) self.setWindowTitle("Numerotation de pages") self.setMinimumWidth(400) + self._color = QColor(0, 0, 0) self._setup_ui() def _setup_ui(self): @@ -45,6 +48,17 @@ class PageNumbersDialog(QDialog): fl.addWidget(self.spin_size) layout.addWidget(grp_font) + # Couleur + color_layout = QHBoxLayout() + color_layout.addWidget(QLabel("Couleur :")) + self.btn_color = QPushButton() + self.btn_color.setFixedSize(60, 28) + self._update_color_button() + self.btn_color.clicked.connect(self._pick_color) + color_layout.addWidget(self.btn_color) + color_layout.addStretch() + layout.addLayout(color_layout) + # Texte grp_text = QGroupBox("Texte") tl = QHBoxLayout(grp_text) @@ -100,6 +114,17 @@ class PageNumbersDialog(QDialog): buttons.rejected.connect(self.reject) layout.addWidget(buttons) + def _pick_color(self): + color = QColorDialog.getColor(self._color, self, "Couleur de la numerotation") + if color.isValid(): + self._color = color + self._update_color_button() + + def _update_color_button(self): + self.btn_color.setStyleSheet( + f"background-color: {self._color.name()}; border: 1px solid #999; border-radius: 4px;" + ) + def get_settings(self): apply_map = { "Toutes les pages": "all", @@ -110,7 +135,7 @@ class PageNumbersDialog(QDialog): "position": self.combo_pos.currentText(), "font_name": self.combo_font.currentText(), "font_size": self.spin_size.value(), - "color": (0, 0, 0), + "color": (self._color.redF(), self._color.greenF(), self._color.blueF()), "prefix": self.edit_prefix.text(), "suffix": self.edit_suffix.text(), "start_number": self.spin_start.value(), diff --git a/app/dialogs/trim_shift_dialog.py b/app/dialogs/trim_shift_dialog.py index bc02085..020c5c1 100644 --- a/app/dialogs/trim_shift_dialog.py +++ b/app/dialogs/trim_shift_dialog.py @@ -1,4 +1,4 @@ -"""Dialogue Trim & Shift.""" +"""Dialogue Decalage du contenu.""" from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, QGroupBox, QDoubleSpinBox, QCheckBox, @@ -9,7 +9,7 @@ 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.setWindowTitle("Decalage du contenu") self.setMinimumWidth(420) self._total_pages = total_pages self._setup_ui() @@ -55,10 +55,10 @@ class TrimShiftDialog(QDialog): 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)") + # Chasse (creep) + grp_creep = QGroupBox("Compensation de chasse (livrets)") cl = QHBoxLayout(grp_creep) - cl.addWidget(QLabel("Creep max :")) + cl.addWidget(QLabel("Chasse max :")) self.spin_creep = QDoubleSpinBox() self.spin_creep.setRange(0, 20) self.spin_creep.setSuffix(" mm") diff --git a/app/engine/crop.py b/app/engine/crop.py index 69bdb17..acf88ec 100644 --- a/app/engine/crop.py +++ b/app/engine/crop.py @@ -5,36 +5,50 @@ 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): + crop_left=0, crop_right=0, pages=None, per_page_settings=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. + per_page_settings : dict {page_index: {"crop_top", "crop_bottom", "crop_left", "crop_right"}} + Si fourni, applique des reglages differents par page. """ reader = PdfReader(input_path) writer = PdfWriter() - if pages is None: + if pages is None and per_page_settings 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 + # Verifier si on a des reglages specifiques pour cette page + if per_page_settings and i in per_page_settings: + ps = per_page_settings[i] + ct = ps.get("crop_top", 0) + cb = ps.get("crop_bottom", 0) + cl = ps.get("crop_left", 0) + cr = ps.get("crop_right", 0) + elif pages is not None and i in pages: + ct, cb, cl, cr = crop_top, crop_bottom, crop_left, crop_right + else: + writer.add_page(page) + continue - # Securite - if new_right <= new_left or new_top <= new_bottom: - writer.add_page(page) - continue + w, h = get_page_size(page) + new_left = float(page.mediabox.left) + cl + new_bottom = float(page.mediabox.bottom) + cb + new_right = float(page.mediabox.right) - cr + new_top = float(page.mediabox.top) - ct - 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 + # 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) diff --git a/app/engine/crop_marks.py b/app/engine/crop_marks.py index a3e0799..1c6e322 100644 --- a/app/engine/crop_marks.py +++ b/app/engine/crop_marks.py @@ -1,6 +1,6 @@ """Moteur Reperes de coupe autonomes.""" -from pypdf import PdfReader, PdfWriter +from pypdf import PdfReader, PdfWriter, PageObject, Transformation from reportlab.pdfgen import canvas from .pdf_utils import get_page_size import io @@ -8,11 +8,18 @@ import io def add_crop_marks(input_path, output_path, mark_length=14.17, mark_offset=7.09, line_width=0.25, color=(0, 0, 0), center_marks=False, - apply_to="all", current_page=0): - """Ajouter des reperes de coupe sur les pages.""" + apply_to="all", current_page=0, **_kwargs): + """Ajouter des reperes de coupe sur les pages. + + Les marques sont placees a l'exterieur de la zone d'impression. + Chaque coin a 2 traits (1 horizontal + 1 vertical) formant un L. + La page est agrandie pour contenir les marques. + """ reader = PdfReader(input_path) writer = PdfWriter() + margin = mark_offset + mark_length + for i, page in enumerate(reader.pages): page_num = i + 1 should_apply = False @@ -27,46 +34,62 @@ def add_crop_marks(input_path, output_path, mark_length=14.17, mark_offset=7.09, if should_apply: w, h = get_page_size(page) + # Canvas agrandi pour inclure les marques + cw = w + 2 * margin + ch = h + 2 * margin + packet = io.BytesIO() - c = canvas.Canvas(packet, pagesize=(w, h)) + c = canvas.Canvas(packet, pagesize=(cw, ch)) c.setLineWidth(line_width) c.setStrokeColorRGB(*color) - # 4 coins - corners = [(0, 0), (w, 0), (0, h), (w, h)] - for cx, cy in corners: - # Traits horizontaux - if cx == 0: - c.line(cx - mark_offset - mark_length, cy, cx - mark_offset, cy) - else: - c.line(cx + mark_offset, cy, cx + mark_offset + mark_length, cy) - # Traits verticaux - if cy == 0: - c.line(cx, cy - mark_offset - mark_length, cx, cy - mark_offset) - else: - c.line(cx, cy + mark_offset, cx, cy + mark_offset + mark_length) + # Les coordonnees de la zone d'impression dans le canvas agrandi + ox, oy = margin, margin # origine de la page originale + + # 4 coins — 2 traits par coin (L exterieur) + # Bas-gauche + c.line(ox - mark_offset - mark_length, oy, ox - mark_offset, oy) + c.line(ox, oy - mark_offset - mark_length, ox, oy - mark_offset) + # Bas-droite + c.line(ox + w + mark_offset, oy, ox + w + mark_offset + mark_length, oy) + c.line(ox + w, oy - mark_offset - mark_length, ox + w, oy - mark_offset) + # Haut-gauche + c.line(ox - mark_offset - mark_length, oy + h, ox - mark_offset, oy + h) + c.line(ox, oy + h + mark_offset, ox, oy + h + mark_offset + mark_length) + # Haut-droite + c.line(ox + w + mark_offset, oy + h, ox + w + mark_offset + mark_length, oy + h) + c.line(ox + w, oy + h + mark_offset, ox + w, oy + h + mark_offset + mark_length) # Reperes de centrage if center_marks: - mid_x = w / 2 - mid_y = h / 2 + mid_x = ox + w / 2 + mid_y = oy + h / 2 ml = mark_length * 0.7 # Haut - c.line(mid_x, h + mark_offset, mid_x, h + mark_offset + ml) + c.line(mid_x, oy + h + mark_offset, mid_x, oy + h + mark_offset + ml) # Bas - c.line(mid_x, -mark_offset, mid_x, -mark_offset - ml) + c.line(mid_x, oy - mark_offset, mid_x, oy - mark_offset - ml) # Gauche - c.line(-mark_offset, mid_y, -mark_offset - ml, mid_y) + c.line(ox - mark_offset, mid_y, ox - mark_offset - ml, mid_y) # Droite - c.line(w + mark_offset, mid_y, w + mark_offset + ml, mid_y) + c.line(ox + w + mark_offset, mid_y, ox + w + mark_offset + ml, mid_y) c.showPage() c.save() packet.seek(0) - overlay = PdfReader(packet) - page.merge_page(overlay.pages[0]) + overlay_reader = PdfReader(packet) + overlay_page = overlay_reader.pages[0] - writer.add_page(page) + # Creer une nouvelle page agrandie + new_page = PageObject.create_blank_page(width=cw, height=ch) + # Placer la page originale au centre (decalee de margin) + page.add_transformation(Transformation().translate(margin, margin)) + new_page.merge_page(page) + # Ajouter les marques de coupe par-dessus + new_page.merge_page(overlay_page) + writer.add_page(new_page) + else: + writer.add_page(page) with open(output_path, "wb") as f: writer.write(f) diff --git a/app/engine/disassemble.py b/app/engine/disassemble.py new file mode 100644 index 0000000..745c8de --- /dev/null +++ b/app/engine/disassemble.py @@ -0,0 +1,39 @@ +"""Moteur Demonter les pages — couper en 2 sans reordonner.""" + +from pypdf import PdfReader, PdfWriter, PageObject, Transformation +from .pdf_utils import get_page_size + + +def disassemble_pages(input_path, output_path): + """Demonter un PDF : couper chaque page en 2 moities (gauche puis droite). + + Contrairement a Delivretiser, les pages ne sont PAS remises dans l'ordre + du livret. On obtient simplement 2x plus de pages dans l'ordre de lecture. + """ + reader = PdfReader(input_path) + writer = PdfWriter() + + for page in reader.pages: + w, h = get_page_size(page) + half_w = w / 2 + + # Moitie gauche + left_page = PageObject.create_blank_page(width=half_w, height=h) + left_overlay = PageObject.create_blank_page(width=half_w, height=h) + left_overlay.merge_page(page) + left_overlay.mediabox.upper_right = (half_w, h) + left_page.merge_page(left_overlay) + writer.add_page(left_page) + + # Moitie droite + right_page = PageObject.create_blank_page(width=half_w, height=h) + right_overlay = PageObject.create_blank_page(width=half_w, height=h) + right_overlay.merge_page(page) + right_overlay.add_transformation(Transformation().translate(-half_w, 0)) + right_overlay.mediabox.upper_right = (half_w, h) + right_page.merge_page(right_overlay) + writer.add_page(right_page) + + 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 index c698e65..5308d57 100644 --- a/app/engine/masking.py +++ b/app/engine/masking.py @@ -10,7 +10,8 @@ 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) + apply_to: "all", "even", "odd", un numero de page (int, 1-based), + ou une liste d'indices (0-based) """ reader = PdfReader(input_path) writer = PdfWriter() @@ -27,6 +28,8 @@ def apply_masking(input_path, output_path, rectangles, apply_to="all"): should_apply = page_num % 2 != 0 elif isinstance(apply_to, int): should_apply = page_num == apply_to + elif isinstance(apply_to, list): + should_apply = i in apply_to if should_apply and rectangles: w, h = get_page_size(page) diff --git a/app/main_window.py b/app/main_window.py index db2432c..228431b 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -40,6 +40,7 @@ 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.disassemble import disassemble_pages from app.engine.trim_shift import trim_and_shift from app.engine.extract_content import extract_text_from_page, extract_images_from_page from app.dialogs.trim_shift_dialog import TrimShiftDialog @@ -63,6 +64,7 @@ class MainWindow(QMainWindow): self._result_file = None self._original_file = None self._crop_settings = {} + self._bleed_settings = None self._undo_stack = [] # pile de fichiers pour annuler self._redo_stack = [] @@ -221,11 +223,12 @@ class MainWindow(QMainWindow): 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) + self._add_action(impose_menu, "Demonter les pages", None, self._do_disassemble) # 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) + self._add_action(pages_menu, "Decalage...", 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)) @@ -236,6 +239,13 @@ class MainWindow(QMainWindow): self._add_action(pages_menu, "Inserer des pages blanches", None, self._do_insert_blank) self._add_action(pages_menu, "Creer un document test...", None, self._do_create_sample) + # Affichage + view_menu = menubar.addMenu("Affichage") + self._rulers_action = QAction("Afficher les regles", self) + self._rulers_action.setCheckable(True) + self._rulers_action.toggled.connect(self._toggle_rulers) + view_menu.addAction(self._rulers_action) + # Annotations annot_menu = menubar.addMenu("Annotations") self._add_action(annot_menu, "Ajouter du texte...", None, self._do_add_text) @@ -319,6 +329,7 @@ class MainWindow(QMainWindow): rtb.addWidget(self._make_section_label("IMPOSITION")) rtb.addAction("Livret", self._do_booklet) rtb.addAction("Delivretiser", self._do_unbooklet) + rtb.addAction("Demonter", self._do_disassemble) rtb.addAction("N-Up", self._do_nup) rtb.addAction("Repetition", self._do_step_repeat) rtb.addAction("Manuel", self._do_manual_impose) @@ -328,7 +339,7 @@ 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("Decalage", 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) @@ -633,6 +644,22 @@ class MainWindow(QMainWindow): self._hide_progress() QMessageBox.critical(self, "Erreur", str(e)) + def _do_disassemble(self): + if not self._require_file(): + return + self._show_progress() + output = self._make_temp() + try: + disassemble_pages(self._current_file, output) + self._show_result(output) + self._status_label.setText("Demontage termine — pages coupees en 2") + except Exception as e: + self._hide_progress() + QMessageBox.critical(self, "Erreur", str(e)) + + def _toggle_rulers(self, checked): + self._viewer.set_rulers_visible(checked) + def _do_trim_shift(self): if not self._require_file(): return @@ -768,18 +795,51 @@ class MainWindow(QMainWindow): def _do_masking(self): if not self._require_file(): return - dlg = MaskingDialog(self) - if dlg.exec(): + from pypdf import PdfReader + from app.engine.pdf_utils import get_page_size + reader = PdfReader(self._current_file) + cur_page = self._viewer.current_page + page = reader.pages[cur_page] + w, h = get_page_size(page) + + self._mask_dlg = MaskingDialog( + page_width_pt=w, page_height_pt=h, + total_pages=len(reader.pages), + current_page=cur_page, + viewer=self._viewer, + parent=self + ) + + def on_mask_accepted(): + if not hasattr(self, '_mask_dlg') or not self._mask_dlg: + return + dlg_ref = self._mask_dlg + self._mask_dlg = None + try: + settings = dlg_ref.get_settings() + except Exception: + return + if not settings.get("rectangles"): + return self._show_progress() - settings = dlg.get_settings() output = self._make_temp() try: apply_masking(self._current_file, output, **settings) self._show_result(output) + self._status_label.setText( + f"Masquage applique — {len(settings['rectangles'])} zone(s)" + ) except Exception as e: self._hide_progress() QMessageBox.critical(self, "Erreur", str(e)) + def on_mask_rejected(): + self._mask_dlg = None + + self._mask_dlg.accepted.connect(on_mask_accepted) + self._mask_dlg.rejected.connect(on_mask_rejected) + self._mask_dlg.show() + def _do_bleeds(self): if not self._require_file(): return @@ -791,6 +851,18 @@ class MainWindow(QMainWindow): try: define_bleeds(self._current_file, output, **settings) self._show_result(output) + # Stocker et afficher l'overlay rouge sur le viewer + from app.engine.pdf_utils import mm_to_pt + self._bleed_settings = { + "top": mm_to_pt(settings["bleed_top"]), + "bottom": mm_to_pt(settings["bleed_bottom"]), + "left": mm_to_pt(settings["bleed_left"]), + "right": mm_to_pt(settings["bleed_right"]), + } + self._viewer.set_bleed_info(self._bleed_settings) + self._status_label.setText( + f"Fonds perdus definis — zone rouge = bleed" + ) except Exception as e: self._hide_progress() QMessageBox.critical(self, "Erreur", str(e)) @@ -804,15 +876,20 @@ class MainWindow(QMainWindow): reader = PdfReader(self._current_file) writer = PdfWriter() for page in reader.pages: - if hasattr(page, 'trimbox'): - page.trimbox = page.mediabox - if hasattr(page, 'bleedbox'): - page.bleedbox = page.mediabox + # Remettre trimbox et bleedbox a la taille de la mediabox + page.trimbox.lower_left = page.mediabox.lower_left + page.trimbox.upper_right = page.mediabox.upper_right + page.bleedbox.lower_left = page.mediabox.lower_left + page.bleedbox.upper_right = page.mediabox.upper_right writer.add_page(page) output = self._make_temp() with open(output, "wb") as f: writer.write(f) self._show_result(output) + # Supprimer l'overlay rouge + self._bleed_settings = None + self._viewer.set_bleed_info(None) + self._status_label.setText("Fonds perdus supprimes") except Exception as e: self._hide_progress() QMessageBox.critical(self, "Erreur", str(e)) @@ -976,20 +1053,21 @@ class MainWindow(QMainWindow): "crop_right": settings["crop_right"], } - # Appliquer le crop depuis l'original + # Appliquer TOUS les crops cumules depuis l'original self._show_progress() source_file = self._original_file or self._current_file output = self._make_temp() try: - crop_pages(source_file, output, **settings) + crop_pages(source_file, output, per_page_settings=self._crop_settings) self._result_file = output self._current_file = output self._viewer.load_pdf(output) self._viewer.go_to_page(cur_page) self._page_panel.load_thumbnails(self._viewer) self._hide_progress() + total_cropped = len(self._crop_settings) self._status_label.setText( - f"Recadrage applique — {len(settings['pages'])} page(s)" + f"Recadrage applique — {total_cropped} page(s) recadree(s) au total" ) QTimer.singleShot(100, self._viewer.zoom_fit) except Exception as e: diff --git a/app/pdf_viewer.py b/app/pdf_viewer.py index a6d2d0e..59fad87 100644 --- a/app/pdf_viewer.py +++ b/app/pdf_viewer.py @@ -1,13 +1,14 @@ -"""Widget de visualisation PDF avec pypdfium2 — V2.5 avec selection redimensionnable.""" +"""Widget de visualisation PDF avec pypdfium2 — V3 avec selection + regles.""" -from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QLabel, +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QScrollArea, QSizePolicy, QApplication) from PyQt6.QtGui import (QImage, QPixmap, QWheelEvent, QPainter, QPen, QColor, - QMouseEvent, QCursor, QBrush) + QMouseEvent, QCursor, QBrush, QFont) from PyQt6.QtCore import Qt, pyqtSignal, QRect, QPoint, QSize import pypdfium2 as pdfium HANDLE_SIZE = 8 # taille des poignees de redimensionnement +RULER_SIZE = 24 # largeur/hauteur des regles en pixels class SelectableImageLabel(QLabel): @@ -231,8 +232,105 @@ class SelectableImageLabel(QLabel): painter.end() +class RulerWidget(QWidget): + """Regle graduee en mm (horizontale ou verticale).""" + + def __init__(self, orientation=Qt.Orientation.Horizontal, parent=None): + super().__init__(parent) + self._orientation = orientation + self._page_size_pt = 0 # taille de la page en points + self._zoom = 1.0 + self._offset = 0 # decalage du scroll + self._img_offset = 0 # offset de l'image dans le label + if orientation == Qt.Orientation.Horizontal: + self.setFixedHeight(RULER_SIZE) + else: + self.setFixedWidth(RULER_SIZE) + + def update_params(self, page_size_pt, zoom, offset, img_offset): + self._page_size_pt = page_size_pt + self._zoom = zoom + self._offset = offset + self._img_offset = img_offset + self.update() + + def paintEvent(self, event): + if self._page_size_pt <= 0: + return + painter = QPainter(self) + painter.fillRect(self.rect(), QColor(245, 245, 245)) + + pen = QPen(QColor(120, 120, 120), 1) + painter.setPen(pen) + font = QFont("Segoe UI", 7) + painter.setFont(font) + + # pixels par mm + px_per_pt = 150 / 72 * self._zoom + px_per_mm = px_per_pt * (72 / 25.4) + page_size_mm = self._page_size_pt * 25.4 / 72 + + # Position de depart de la page dans le widget + page_start = self._img_offset - self._offset + + horiz = self._orientation == Qt.Orientation.Horizontal + + # Determiner le pas des graduations + if px_per_mm >= 4: + step_mm = 5 + sub_step_mm = 1 + elif px_per_mm >= 2: + step_mm = 10 + sub_step_mm = 5 + else: + step_mm = 20 + sub_step_mm = 10 + + # Dessiner les graduations + mm_val = 0 + while mm_val <= page_size_mm: + px = page_start + mm_val * px_per_mm + + if horiz: + if px < 0 or px > self.width(): + mm_val += sub_step_mm + continue + else: + if px < 0 or px > self.height(): + mm_val += sub_step_mm + continue + + is_major = (mm_val % step_mm == 0) + tick_len = RULER_SIZE * 0.6 if is_major else RULER_SIZE * 0.3 + + if horiz: + x = int(px) + painter.drawLine(x, RULER_SIZE - int(tick_len), x, RULER_SIZE) + if is_major and mm_val > 0: + painter.drawText(x + 2, RULER_SIZE - int(tick_len) - 1, str(int(mm_val))) + else: + y = int(px) + painter.drawLine(RULER_SIZE - int(tick_len), y, RULER_SIZE, y) + if is_major and mm_val > 0: + painter.save() + painter.translate(RULER_SIZE - int(tick_len) - 2, y + 2) + painter.rotate(-90) + painter.drawText(0, 0, str(int(mm_val))) + painter.restore() + + mm_val += sub_step_mm + + # Ligne de bord + if horiz: + painter.drawLine(0, RULER_SIZE - 1, self.width(), RULER_SIZE - 1) + else: + painter.drawLine(RULER_SIZE - 1, 0, RULER_SIZE - 1, self.height()) + + painter.end() + + class PdfViewer(QWidget): - """Widget d'apercu PDF avec zoom molette, navigation et selection.""" + """Widget d'apercu PDF avec zoom molette, navigation, selection et regles.""" page_changed = pyqtSignal(int, int) selection_changed = pyqtSignal(float, float, float, float) @@ -247,6 +345,8 @@ class PdfViewer(QWidget): self._zoom = 1.0 self._page_width_pt = 0 self._page_height_pt = 0 + self._rulers_visible = False + self._bleed_info = None # dict avec top/bottom/left/right en pt self._setup_ui() def _setup_ui(self): @@ -254,6 +354,18 @@ class PdfViewer(QWidget): layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) + # Regle horizontale + self._h_ruler = RulerWidget(Qt.Orientation.Horizontal) + self._h_ruler.setVisible(False) + + # Zone centrale : regle verticale + scroll + center_layout = QHBoxLayout() + center_layout.setContentsMargins(0, 0, 0, 0) + center_layout.setSpacing(0) + + self._v_ruler = RulerWidget(Qt.Orientation.Vertical) + self._v_ruler.setVisible(False) + self._scroll = QScrollArea() self._scroll.setWidgetResizable(True) self._scroll.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -268,7 +380,41 @@ class PdfViewer(QWidget): self._scroll.setWidget(self._image_label) self._scroll.wheelEvent = self._on_wheel - layout.addWidget(self._scroll, 1) + self._scroll.horizontalScrollBar().valueChanged.connect(self._update_rulers) + self._scroll.verticalScrollBar().valueChanged.connect(self._update_rulers) + + center_layout.addWidget(self._v_ruler) + center_layout.addWidget(self._scroll, 1) + + # Coin superieur gauche (vide) quand regles actives + h_ruler_layout = QHBoxLayout() + h_ruler_layout.setContentsMargins(0, 0, 0, 0) + h_ruler_layout.setSpacing(0) + self._ruler_corner = QWidget() + self._ruler_corner.setFixedSize(RULER_SIZE, RULER_SIZE) + self._ruler_corner.setStyleSheet("background: #f5f5f5;") + self._ruler_corner.setVisible(False) + h_ruler_layout.addWidget(self._ruler_corner) + h_ruler_layout.addWidget(self._h_ruler, 1) + + layout.addLayout(h_ruler_layout) + layout.addLayout(center_layout) + + def set_rulers_visible(self, visible): + self._rulers_visible = visible + self._h_ruler.setVisible(visible) + self._v_ruler.setVisible(visible) + self._ruler_corner.setVisible(visible) + if visible: + self._update_rulers() + + def rulers_visible(self): + return self._rulers_visible + + def set_bleed_info(self, bleed_info): + """Definir les infos de bleed pour l'overlay rouge. None pour desactiver.""" + self._bleed_info = bleed_info + self._render_page() def set_select_mode(self, enabled): self._image_label.set_select_mode(enabled) @@ -279,6 +425,25 @@ class PdfViewer(QWidget): def show_selection(self, left_ratio, top_ratio, right_ratio, bottom_ratio): self._image_label.set_selection_from_margins(left_ratio, top_ratio, right_ratio, bottom_ratio) + def _update_rulers(self): + if not self._rulers_visible or not self._pdf: + return + # Calculer l'offset de l'image dans le scroll area + pm = self._image_label.pixmap() + if not pm: + return + img_w, img_h = pm.width(), pm.height() + label_w = self._image_label.width() + label_h = self._image_label.height() + img_off_x = max(0, (label_w - img_w) // 2) + img_off_y = max(0, (label_h - img_h) // 2) + + scroll_x = self._scroll.horizontalScrollBar().value() + scroll_y = self._scroll.verticalScrollBar().value() + + self._h_ruler.update_params(self._page_width_pt, self._zoom, scroll_x, img_off_x) + self._v_ruler.update_params(self._page_height_pt, self._zoom, scroll_y, img_off_y) + def _on_wheel(self, event): if event.modifiers() & Qt.KeyboardModifier.ControlModifier: delta = event.angleDelta().y() @@ -324,11 +489,43 @@ class PdfViewer(QWidget): pil_image = bitmap.to_pil() data = pil_image.tobytes("raw", "RGBA") qimage = QImage(data, pil_image.width, pil_image.height, QImage.Format.Format_RGBA8888) + + # Overlay bleed si actif + if self._bleed_info: + from PyQt6.QtGui import QPainter as _P + px_per_pt = scale + bt = self._bleed_info.get("top", 0) * px_per_pt + bb = self._bleed_info.get("bottom", 0) * px_per_pt + bl = self._bleed_info.get("left", 0) * px_per_pt + br = self._bleed_info.get("right", 0) * px_per_pt + img_w = qimage.width() + img_h = qimage.height() + + painter = _P(qimage) + painter.setBrush(QColor(255, 0, 0, 50)) + painter.setPen(QPen(QColor(255, 0, 0, 120), 1, Qt.PenStyle.DashLine)) + # Haut + if bt > 0: + painter.drawRect(0, 0, img_w, int(bt)) + # Bas + if bb > 0: + painter.drawRect(0, img_h - int(bb), img_w, int(bb)) + # Gauche + if bl > 0: + painter.drawRect(0, int(bt), int(bl), img_h - int(bt) - int(bb)) + # Droite + if br > 0: + painter.drawRect(img_w - int(br), int(bt), int(br), img_h - int(bt) - int(bb)) + painter.end() + pixmap = QPixmap.fromImage(qimage) self._image_label.setPixmap(pixmap) self._image_label.adjustSize() self.page_changed.emit(self._current_page, self._total_pages) + if self._rulers_visible: + self._update_rulers() + def _zoom_fit(self): if not self._pdf or self._current_page >= self._total_pages: return diff --git a/app/updater.py b/app/updater.py index 5bff866..89f9b69 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 = "3.5.0" +VERSION = "3.6.0" def get_remote_version(): diff --git a/version.txt b/version.txt index 1545d96..40c341b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -3.5.0 +3.6.0