Files
copydev-imposing-tool/app/main_window.py
Jules 5e368ef260 V3.3.1 — Impression directe via QPrinter
- Impression directe sans passer par un lecteur PDF externe
- Dialogue d'impression natif Windows (QPrintDialog)
- Choix imprimante, copies, pages, orientation
- Rendu haute resolution (DPI de l'imprimante)
- Centrage automatique sur la page
- Fonctionne sans Adobe/Edge/lecteur PDF installe

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:43:32 +01:00

1390 lines
54 KiB
Python

"""Fenetre principale de Copydev Imposing Tool — V2."""
import os
import sys
import tempfile
import shutil
from PyQt6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QFileDialog, QMessageBox, QStatusBar,
QSplitter, QProgressBar, QApplication, QToolBar)
from PyQt6.QtCore import Qt, QTimer, QRect
from PyQt6.QtGui import QAction, QIcon, QDragEnterEvent, QDropEvent, QPageLayout
from app.pdf_viewer import PdfViewer
from app.page_panel import PagePanel
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.dialogs.crop_marks_dialog import CropMarksDialog
from app.dialogs.crop_dialog import CropDialog
from app.dialogs.add_text_dialog import AddTextDialog
from app.dialogs.add_image_dialog import AddImageDialog
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
from app.engine.crop_marks import add_crop_marks, remove_crop_marks
from app.engine.crop import crop_pages
from app.engine.add_content import add_text_to_pages, add_image_to_pages
from app.engine.unbooklet import unbooklet
from app.engine.trim_shift import trim_and_shift
from app.engine.extract_content import extract_text_from_page, extract_images_from_page
from app.dialogs.trim_shift_dialog import TrimShiftDialog
from app.dialogs.edit_content_dialog import EditContentDialog
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
from app.updater import VERSION
self.setWindowTitle(f"CopyDev Imposing Tool v{VERSION} — www.copydev.fr")
self.setMinimumSize(1100, 750)
# Icone
_base = sys._MEIPASS if getattr(sys, 'frozen', False) else os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
icon_path = os.path.join(_base, "resources", "logo.png")
if os.path.exists(icon_path):
self.setWindowIcon(QIcon(icon_path))
self._current_file = None
self._result_file = None
self._original_file = None
self._crop_settings = {}
self._undo_stack = [] # pile de fichiers pour annuler
self._redo_stack = []
# Drag & drop
self.setAcceptDrops(True)
self._setup_menu()
self._setup_toolbar()
self._setup_ui()
self._setup_statusbar()
# Style gris moderne avec accents cyan
self.setStyleSheet("""
* { font-family: 'Segoe UI', sans-serif; }
QMainWindow { background: #e8e8e8; }
QMenuBar {
background: #f2f2f2; color: #2d2d2d; font-size: 13px;
padding: 2px 0; border-bottom: 1px solid #d0d0d0;
}
QMenuBar::item { padding: 5px 12px; border-radius: 4px; margin: 1px 2px; }
QMenuBar::item:selected { background: #d8d8d8; }
QMenu {
background: #ffffff; color: #2d2d2d; border: 1px solid #d0d0d0;
border-radius: 8px; padding: 4px;
}
QMenu::item { padding: 6px 24px; border-radius: 4px; }
QMenu::item:selected { background: #e0f4ff; color: #006fa6; }
QMenu::separator { background: #e0e0e0; height: 1px; margin: 4px 12px; }
QToolBar {
background: #f2f2f2; border: none; spacing: 2px; padding: 4px;
border-bottom: 1px solid #d0d0d0;
}
QToolBar QToolButton {
color: #444; padding: 5px 10px; border-radius: 6px;
font-size: 12px; border: 1px solid transparent;
}
QToolBar QToolButton:hover {
background: #d8f0f8; border: 1px solid #00b4d8; color: #006fa6;
}
QToolBar QToolButton:pressed { background: #b8e6f0; }
QStatusBar {
background: #f2f2f2; color: #888; font-size: 11px;
border-top: 1px solid #d0d0d0;
}
QSplitter::handle { background: #d0d0d0; width: 2px; }
QSplitter::handle:hover { background: #00b4d8; }
QDialog { background: #f5f5f5; color: #2d2d2d; }
QGroupBox {
color: #2d2d2d; border: 1px solid #d0d0d0; border-radius: 8px;
margin-top: 12px; padding-top: 16px; font-weight: bold;
background: #ffffff;
}
QGroupBox::title {
subcontrol-origin: margin; left: 12px; padding: 0 6px;
color: #00b4d8;
}
QLabel { color: #2d2d2d; }
QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QTextEdit {
background: #ffffff; color: #2d2d2d; border: 1px solid #ccc;
border-radius: 6px; padding: 5px 8px;
}
QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus, QTextEdit:focus {
border: 1px solid #00b4d8;
}
QComboBox::drop-down { border: none; padding-right: 8px; }
QComboBox QAbstractItemView {
background: #ffffff; color: #2d2d2d; border: 1px solid #d0d0d0;
selection-background-color: #e0f4ff;
selection-color: #006fa6;
}
QPushButton {
background: #f0f0f0; color: #2d2d2d; border: 1px solid #ccc;
border-radius: 6px; padding: 6px 14px; font-size: 12px;
}
QPushButton:hover { background: #d8f0f8; border-color: #00b4d8; color: #006fa6; }
QPushButton:pressed { background: #b8e6f0; }
QRadioButton, QCheckBox { color: #2d2d2d; spacing: 6px; }
QDialogButtonBox QPushButton { min-width: 80px; }
QProgressBar { background: #e0e0e0; border: none; height: 3px; }
QProgressBar::chunk { background: #00b4d8; border-radius: 1px; }
QScrollBar:vertical {
background: #f0f0f0; width: 10px; border: none;
}
QScrollBar::handle:vertical {
background: #c0c0c0; border-radius: 5px; min-height: 30px;
}
QScrollBar::handle:vertical:hover { background: #a0a0a0; }
QScrollBar::add-line, QScrollBar::sub-line { height: 0; }
QScrollBar:horizontal {
background: #f0f0f0; height: 10px; border: none;
}
QScrollBar::handle:horizontal {
background: #c0c0c0; border-radius: 5px; min-width: 30px;
}
QScrollBar::handle:horizontal:hover { background: #a0a0a0; }
""")
# ── Drag & Drop ──
def dragEnterEvent(self, event: QDragEnterEvent):
if event.mimeData().hasUrls():
for url in event.mimeData().urls():
if url.toLocalFile().lower().endswith('.pdf'):
event.acceptProposedAction()
return
def dropEvent(self, event: QDropEvent):
for url in event.mimeData().urls():
path = url.toLocalFile()
if path.lower().endswith('.pdf'):
self._open_pdf(path)
return
# ── Menu ──
def _setup_menu(self):
menubar = self.menuBar()
# Fichier
file_menu = menubar.addMenu("Fichier")
self._add_action(file_menu, "Ouvrir PDF...", "Ctrl+O", self._open_file)
self._add_action(file_menu, "Enregistrer le resultat...", "Ctrl+S", self._save_result)
self._add_action(file_menu, "Imprimer...", "Ctrl+P", self._do_print)
file_menu.addSeparator()
self._add_action(file_menu, "Fusionner des PDF...", "Ctrl+M", self._do_merge)
file_menu.addSeparator()
self._add_action(file_menu, "Quitter", "Ctrl+Q", self.close)
# Edition
edit_menu = menubar.addMenu("Edition")
self._add_action(edit_menu, "Annuler", "Ctrl+Z", self._do_undo)
self._add_action(edit_menu, "Refaire", "Ctrl+Y", self._do_redo)
# Imposition
impose_menu = menubar.addMenu("Imposition")
self._add_action(impose_menu, "Creer un livret", None, self._do_booklet)
self._add_action(impose_menu, "N pages par feuille", None, self._do_nup)
self._add_action(impose_menu, "Repetition (Step && Repeat)", None, self._do_step_repeat)
self._add_action(impose_menu, "Imposition manuelle", None, self._do_manual_impose)
self._add_action(impose_menu, "Joindre deux pages", None, self._do_join_pages)
impose_menu.addSeparator()
self._add_action(impose_menu, "Delivretiser", None, self._do_unbooklet)
# Pages
pages_menu = menubar.addMenu("Pages")
self._add_action(pages_menu, "Recadrer...", None, self._do_crop)
self._add_action(pages_menu, "Trim && Shift...", None, self._do_trim_shift)
pages_menu.addSeparator()
self._add_action(pages_menu, "Pivoter +90", None, lambda: self._rotate_current(90))
self._add_action(pages_menu, "Pivoter -90", None, lambda: self._rotate_current(-90))
self._add_action(pages_menu, "Pivoter 180", None, lambda: self._rotate_current(180))
self._add_action(pages_menu, "Inverser l'ordre des pages", None, self._do_reverse_pages)
self._add_action(pages_menu, "Separer paires/impaires", None, self._do_shuffle_even_odd)
pages_menu.addSeparator()
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)
# Annotations
annot_menu = menubar.addMenu("Annotations")
self._add_action(annot_menu, "Editer le contenu...", None, self._do_edit_content)
annot_menu.addSeparator()
self._add_action(annot_menu, "Ajouter du texte...", None, self._do_add_text)
self._add_action(annot_menu, "Ajouter une image...", None, self._do_add_image)
annot_menu.addSeparator()
self._add_action(annot_menu, "Numerotation de pages", None, self._do_page_numbers)
self._add_action(annot_menu, "Masquage (ruban blanc)", None, self._do_masking)
annot_menu.addSeparator()
annot_menu.addSeparator()
self._add_action(annot_menu, "Reperes de coupe...", None, self._do_crop_marks)
self._add_action(annot_menu, "Enlever les reperes de coupe", None, self._do_remove_crop_marks)
annot_menu.addSeparator()
self._add_action(annot_menu, "Definir les fonds perdus", None, self._do_bleeds)
self._add_action(annot_menu, "Enlever les fonds perdus", None, self._do_remove_bleeds)
# Macros
self._macros_menu = menubar.addMenu("Macros")
self._add_action(self._macros_menu, "Enregistrer la derniere action...", None, self._do_save_macro)
self._add_action(self._macros_menu, "Gerer les macros...", None, self._do_manage_macros)
self._macros_menu.addSeparator()
self._refresh_macros_menu()
# Aide
help_menu = menubar.addMenu("Aide")
self._add_action(help_menu, "Rechercher une mise a jour...", None, self._do_check_update)
help_menu.addSeparator()
self._add_action(help_menu, "A propos de CopyDev Imposing Tool", None, self._show_about)
def _add_action(self, menu, text, shortcut, callback):
act = QAction(text, self)
if shortcut:
act.setShortcut(shortcut)
act.triggered.connect(callback)
menu.addAction(act)
# ── Toolbars ──
def _setup_toolbar(self):
# Toolbar horizontale en haut (fichier + zoom)
tb = QToolBar("Fichier")
tb.setMovable(False)
self.addToolBar(tb)
tb.addAction("Ouvrir", self._open_file)
tb.addAction("Enregistrer", self._save_result)
tb.addAction("Imprimer", self._do_print)
tb.addSeparator()
tb.addAction("Zoom +", lambda: self._viewer.zoom_in())
tb.addAction("Zoom -", lambda: self._viewer.zoom_out())
tb.addAction("Ajuster", lambda: self._viewer.zoom_fit())
tb.addSeparator()
self._page_label = QLabel(" Aucun fichier ")
self._page_label.setStyleSheet("color: #aaa; padding: 0 10px;")
tb.addWidget(self._page_label)
# Toolbar verticale a droite (outils)
rtb = QToolBar("Outils")
rtb.setMovable(False)
rtb.setOrientation(Qt.Orientation.Vertical)
rtb.setStyleSheet("""
QToolBar {
background: #f2f2f2; border-left: 1px solid #d0d0d0;
padding: 6px 4px; spacing: 1px;
}
QToolBar QToolButton {
color: #555; padding: 7px 4px; border-radius: 6px;
font-size: 11px; min-width: 64px;
border: 1px solid transparent;
}
QToolBar QToolButton:hover {
background: #d8f0f8; color: #006fa6;
border: 1px solid #00b4d8;
}
QToolBar QToolButton:pressed { background: #b8e6f0; }
""")
self.addToolBar(Qt.ToolBarArea.RightToolBarArea, rtb)
# Section Imposition
rtb.addWidget(self._make_section_label("IMPOSITION"))
rtb.addAction("Livret", self._do_booklet)
rtb.addAction("Delivretiser", self._do_unbooklet)
rtb.addAction("N-Up", self._do_nup)
rtb.addAction("Repetition", self._do_step_repeat)
rtb.addAction("Manuel", self._do_manual_impose)
rtb.addAction("Joindre", self._do_join_pages)
rtb.addSeparator()
# Section Pages
rtb.addWidget(self._make_section_label("PAGES"))
rtb.addAction("Recadrer", self._do_crop)
rtb.addAction("Trim/Shift", self._do_trim_shift)
rtb.addAction("Pg blanche", self._do_insert_blank)
rtb.addAction("Inverser", self._do_reverse_pages)
rtb.addAction("Fusionner", self._do_merge)
rtb.addSeparator()
# Section Annotations
rtb.addWidget(self._make_section_label("ANNOTATIONS"))
rtb.addAction("Editer", self._do_edit_content)
rtb.addAction("Texte", self._do_add_text)
rtb.addAction("Image", self._do_add_image)
rtb.addAction("Numeros", self._do_page_numbers)
rtb.addAction("Masquer", self._do_masking)
rtb.addSeparator()
# Section Reperes
rtb.addWidget(self._make_section_label("REPERES"))
rtb.addAction("Coupe +", self._do_crop_marks)
rtb.addAction("Coupe -", self._do_remove_crop_marks)
rtb.addAction("Bleed +", self._do_bleeds)
rtb.addAction("Bleed -", self._do_remove_bleeds)
def _make_section_label(self, text):
lbl = QLabel(text)
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
lbl.setStyleSheet("""
color: #00b4d8; font-size: 9px; font-weight: bold;
letter-spacing: 2px; padding: 8px 0 3px 0;
border-bottom: 1px solid #d0d0d0; margin: 0 4px;
""")
return lbl
# ── UI principale ──
def _setup_ui(self):
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Splitter : vignettes | viewer
splitter = QSplitter(Qt.Orientation.Horizontal)
# Panneau vignettes
self._page_panel = PagePanel()
self._page_panel.page_selected.connect(self._on_thumbnail_click)
self._page_panel.page_delete_requested.connect(self._on_page_delete)
self._page_panel.page_insert_requested.connect(self._on_page_insert)
self._page_panel.pages_reordered.connect(self._on_pages_reordered)
self._page_panel.page_rotate_requested.connect(self._on_page_rotate)
splitter.addWidget(self._page_panel)
# Viewer PDF
self._viewer = PdfViewer()
self._viewer.page_changed.connect(self._on_page_changed)
splitter.addWidget(self._viewer)
splitter.setSizes([160, 900])
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
layout.addWidget(splitter)
# Barre de progression
self._progress = QProgressBar()
self._progress.setFixedHeight(3)
self._progress.setTextVisible(False)
self._progress.setStyleSheet("""
QProgressBar { background: #0f0f23; border: none; }
QProgressBar::chunk { background: #0066cc; }
""")
self._progress.hide()
layout.addWidget(self._progress)
# ── Statusbar ──
def _setup_statusbar(self):
self._statusbar = QStatusBar()
self.setStatusBar(self._statusbar)
self._status_label = QLabel("Pret — Glissez un PDF ou utilisez Fichier > Ouvrir")
self._statusbar.addWidget(self._status_label, 1)
self._file_label = QLabel("")
self._statusbar.addPermanentWidget(self._file_label)
brand = QLabel(" CopyDev — contact@copydev.fr ")
brand.setStyleSheet("color: #555; font-size: 10px;")
self._statusbar.addPermanentWidget(brand)
# ── Callbacks ──
def _on_page_changed(self, page, total):
self._page_label.setText(f" Page {page + 1} / {total} | Zoom {int(self._viewer.zoom_level * 100)}% ")
self._page_panel.select_page(page)
def _on_thumbnail_click(self, index):
self._viewer.go_to_page(index)
def _on_page_delete(self, index):
if not self._current_file:
return
from pypdf import PdfReader, PdfWriter
reader = PdfReader(self._current_file)
if len(reader.pages) <= 1:
QMessageBox.warning(self, "Erreur", "Impossible de supprimer la derniere page.")
return
writer = PdfWriter()
for i, page in enumerate(reader.pages):
if i != index:
writer.add_page(page)
output = self._make_temp()
with open(output, "wb") as f:
writer.write(f)
self._show_result(output)
def _on_page_insert(self, after_index):
if not self._current_file:
return
from pypdf import PdfReader, PdfWriter
from app.engine.pdf_utils import get_page_size
reader = PdfReader(self._current_file)
writer = PdfWriter()
ref_page = reader.pages[min(after_index, len(reader.pages) - 1)]
w, h = get_page_size(ref_page)
for i, page in enumerate(reader.pages):
writer.add_page(page)
if i == after_index:
writer.add_blank_page(w, h)
output = self._make_temp()
with open(output, "wb") as f:
writer.write(f)
self._show_result(output)
def _on_pages_reordered(self, indices):
if not self._current_file:
return
from pypdf import PdfReader, PdfWriter
reader = PdfReader(self._current_file)
writer = PdfWriter()
for idx in indices:
if 0 <= idx < len(reader.pages):
writer.add_page(reader.pages[idx])
output = self._make_temp()
with open(output, "wb") as f:
writer.write(f)
self._current_file = output
self._result_file = output
# ── Fichiers ──
def _open_file(self):
path, _ = QFileDialog.getOpenFileName(self, "Ouvrir un fichier PDF", "", "PDF (*.pdf)")
if path:
self._open_pdf(path)
def _open_pdf(self, path):
self._current_file = path
self._result_file = None
self._original_file = path # reset l'original
self._crop_settings = {} # reset les crops
self._viewer.load_pdf(path)
self._page_panel.load_thumbnails(self._viewer)
self._file_label.setText(os.path.basename(path))
self._status_label.setText(f"Fichier charge : {os.path.basename(path)}")
QTimer.singleShot(100, self._viewer.zoom_fit)
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"
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
_FUN_MESSAGES = [
"Pliage des pages en origami...",
"Consultation des esprits de Gutenberg...",
"Negociation avec les pixels...",
"Alignement des atomes de papier...",
"Invocation du dieu de l'imposition...",
"Repassage des pages froissees...",
"Calibrage du massicot virtuel...",
"Appel a un ami imprimeur...",
"Les pages font la queue...",
"Rechauffement de l'encre numerique...",
"Comptage des points par pouce...",
"Meditation sur les marges...",
"Rangement des pages par ordre alphabetique... non, numerique !",
"Verification que le papier est bien a plat...",
]
def _show_progress(self):
import random
self._progress.setRange(0, 0)
self._progress.show()
msg = random.choice(self._FUN_MESSAGES)
self._status_label.setText(msg)
QApplication.processEvents()
def _hide_progress(self):
self._progress.hide()
self._progress.setRange(0, 1)
def _show_result(self, output_path):
# Sauvegarder l'etat actuel pour annulation
if self._current_file:
self._undo_stack.append(self._current_file)
self._redo_stack.clear()
if len(self._undo_stack) > 20:
self._undo_stack.pop(0)
self._result_file = output_path
self._current_file = output_path
self._viewer.load_pdf(output_path)
self._page_panel.load_thumbnails(self._viewer)
self._hide_progress()
self._status_label.setText("Traitement termine")
QTimer.singleShot(100, self._viewer.zoom_fit)
def _do_undo(self):
if not self._undo_stack:
return
self._redo_stack.append(self._current_file)
prev = self._undo_stack.pop()
self._current_file = prev
self._result_file = prev
self._viewer.load_pdf(prev)
self._page_panel.load_thumbnails(self._viewer)
self._status_label.setText("Annule")
QTimer.singleShot(100, self._viewer.zoom_fit)
def _do_redo(self):
if not self._redo_stack:
return
self._undo_stack.append(self._current_file)
nxt = self._redo_stack.pop()
self._current_file = nxt
self._result_file = nxt
self._viewer.load_pdf(nxt)
self._page_panel.load_thumbnails(self._viewer)
self._status_label.setText("Refait")
QTimer.singleShot(100, self._viewer.zoom_fit)
# ── Rotation ──
def _on_page_rotate(self, page_index, degrees):
if not self._current_file:
return
from pypdf import PdfReader, PdfWriter
reader = PdfReader(self._current_file)
writer = PdfWriter()
for i, page in enumerate(reader.pages):
if i == page_index:
page = page.rotate(degrees)
writer.add_page(page)
output = self._make_temp()
with open(output, "wb") as f:
writer.write(f)
self._show_result(output)
def _rotate_current(self, degrees):
if not self._require_file():
return
self._on_page_rotate(self._viewer.current_page, degrees)
def _do_reverse_pages(self):
if not self._require_file():
return
from pypdf import PdfReader, PdfWriter
self._show_progress()
reader = PdfReader(self._current_file)
writer = PdfWriter()
for page in reversed(reader.pages):
writer.add_page(page)
output = self._make_temp()
with open(output, "wb") as f:
writer.write(f)
self._show_result(output)
self._status_label.setText("Ordre des pages inverse")
def _do_unbooklet(self):
if not self._require_file():
return
self._show_progress()
output = self._make_temp()
try:
unbooklet(self._current_file, output)
self._show_result(output)
self._status_label.setText("Delivretisation terminee")
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
def _do_trim_shift(self):
if not self._require_file():
return
from pypdf import PdfReader
reader = PdfReader(self._current_file)
dlg = TrimShiftDialog(total_pages=len(reader.pages), parent=self)
if dlg.exec():
self._show_progress()
settings = dlg.get_settings()
output = self._make_temp()
try:
trim_and_shift(self._current_file, output, **settings)
self._last_action = {"action": "trim_shift", "settings": settings}
self._show_result(output)
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
# ── Actions imposition ──
def _do_booklet(self):
if not self._require_file():
return
dlg = BookletDialog(self)
if dlg.exec():
self._show_progress()
settings = dlg.get_settings()
output = self._make_temp()
try:
create_booklet(self._current_file, output, **settings)
self._show_result(output)
self._last_action = {"action": "booklet", "settings": settings}
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
def _do_nup(self):
if not self._require_file():
return
dlg = NupDialog(self)
if dlg.exec():
self._show_progress()
settings = dlg.get_settings()
output = self._make_temp()
try:
nup_pages(self._current_file, output, **settings)
self._last_action = {"action": "nup", "settings": settings}
self._show_result(output)
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
def _do_step_repeat(self):
if not self._require_file():
return
dlg = StepRepeatDialog(self)
if dlg.exec():
self._show_progress()
settings = dlg.get_settings()
output = self._make_temp()
try:
step_and_repeat(self._current_file, output, **settings)
self._last_action = {"action": "step_repeat", "settings": settings}
self._show_result(output)
except Exception as e:
self._hide_progress()
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():
self._show_progress()
settings = dlg.get_settings()
output = self._make_temp()
try:
manual_impose(self._current_file, output, **settings)
self._show_result(output)
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
def _do_join_pages(self):
if not self._require_file():
return
dlg = JoinPagesDialog(self)
if dlg.exec():
self._show_progress()
settings = dlg.get_settings()
output = self._make_temp()
try:
join_pages(self._current_file, output, **settings)
self._show_result(output)
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
def _do_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():
self._show_progress()
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:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
def _do_page_numbers(self):
if not self._require_file():
return
dlg = PageNumbersDialog(self)
if dlg.exec():
self._show_progress()
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:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
def _do_masking(self):
if not self._require_file():
return
dlg = MaskingDialog(self)
if dlg.exec():
self._show_progress()
settings = dlg.get_settings()
output = self._make_temp()
try:
apply_masking(self._current_file, output, **settings)
self._show_result(output)
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
def _do_bleeds(self):
if not self._require_file():
return
dlg = BleedsDialog(self)
if dlg.exec():
self._show_progress()
settings = dlg.get_settings()
output = self._make_temp()
try:
define_bleeds(self._current_file, output, **settings)
self._show_result(output)
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
def _do_remove_bleeds(self):
if not self._require_file():
return
from pypdf import PdfReader, PdfWriter
self._show_progress()
try:
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
writer.add_page(page)
output = self._make_temp()
with open(output, "wb") as f:
writer.write(f)
self._show_result(output)
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
def _do_edit_content(self):
if not self._require_file():
return
cur = self._viewer.current_page
texts = extract_text_from_page(self._current_file, cur)
images = extract_images_from_page(self._current_file, cur)
dlg = EditContentDialog(text_items=texts, image_items=images, parent=self)
if dlg.exec():
actions = dlg.get_actions()
if not actions:
return
self._show_progress()
from pypdf import PdfReader, PdfWriter
from app.engine.pdf_utils import get_page_size
from reportlab.pdfgen import canvas as rl_canvas
import io
reader = PdfReader(self._current_file)
writer = PdfWriter()
page = reader.pages[cur]
w, h = get_page_size(page)
# Appliquer les masquages de texte
mask_lines = [a["line_index"] for a in actions if a["type"] == "mask_text"]
if mask_lines:
packet = io.BytesIO()
c = rl_canvas.Canvas(packet, pagesize=(w, h))
c.setFillColorRGB(1, 1, 1)
line_h = h / max(len(texts), 1)
for li in mask_lines:
y = h - (li + 1) * line_h
c.rect(0, y, w, line_h, fill=1, stroke=0)
c.showPage()
c.save()
packet.seek(0)
overlay = PdfReader(packet)
page.merge_page(overlay.pages[0])
# Remplacer les images
for a in actions:
if a["type"] == "replace_image" and a.get("new_path"):
from app.engine.extract_content import replace_image_in_page
# On fait un write intermediaire
tmp = self._make_temp()
for ii, pp in enumerate(reader.pages):
writer.add_page(pp)
with open(tmp, "wb") as f:
writer.write(f)
replace_image_in_page(tmp, tmp, cur, a["name"], a["new_path"])
reader = PdfReader(tmp)
writer = PdfWriter()
for pp in reader.pages:
writer.add_page(pp)
output = self._make_temp()
with open(output, "wb") as f:
writer.write(f)
self._show_result(output)
return
for i, p in enumerate(reader.pages):
writer.add_page(p)
output = self._make_temp()
with open(output, "wb") as f:
writer.write(f)
self._show_result(output)
self._status_label.setText("Contenu edite")
def _do_add_text(self):
if not self._require_file():
return
from pypdf import PdfReader
reader = PdfReader(self._current_file)
dlg = AddTextDialog(total_pages=len(reader.pages),
current_page=self._viewer.current_page, parent=self)
if dlg.exec():
settings = dlg.get_settings()
if not settings["text"].strip():
return
self._show_progress()
output = self._make_temp()
try:
add_text_to_pages(self._current_file, output, **settings)
self._show_result(output)
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
def _do_add_image(self):
if not self._require_file():
return
from pypdf import PdfReader
reader = PdfReader(self._current_file)
dlg = AddImageDialog(total_pages=len(reader.pages),
current_page=self._viewer.current_page, parent=self)
if dlg.exec():
settings = dlg.get_settings()
if not settings.get("image_path"):
QMessageBox.warning(self, "Erreur", "Aucune image selectionnee.")
return
self._show_progress()
output = self._make_temp()
try:
add_image_to_pages(self._current_file, output, **settings)
self._show_result(output)
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
def _do_crop(self):
if not self._require_file():
return
from pypdf import PdfReader
from app.engine.pdf_utils import get_page_size, pt_to_mm
# Toujours travailler depuis l'original pour permettre la re-edition
source = self._original_file or self._current_file
reader = PdfReader(source)
cur_page = self._viewer.current_page
page = reader.pages[cur_page]
w, h = get_page_size(page)
# Recharger l'original dans le viewer pour voir la page entiere
self._viewer.load_pdf(source)
self._viewer.go_to_page(cur_page)
self._page_panel.load_thumbnails(self._viewer)
QApplication.processEvents()
# Si on a des settings de crop existants pour cette page, pre-remplir
existing = self._crop_settings.get(cur_page, None)
self._crop_dlg = CropDialog(
page_width_pt=w, page_height_pt=h,
total_pages=len(reader.pages),
current_page=cur_page,
viewer=self._viewer,
existing_crop=existing,
parent=self
)
def on_crop_accepted():
if not hasattr(self, '_crop_dlg') or not self._crop_dlg:
return
dlg_ref = self._crop_dlg
self._crop_dlg = None
try:
settings = dlg_ref.get_settings()
except Exception:
return
if not settings.get("pages"):
return
# Sauvegarder les settings de crop par page
for pg in settings["pages"]:
self._crop_settings[pg] = {
"crop_top": settings["crop_top"],
"crop_bottom": settings["crop_bottom"],
"crop_left": settings["crop_left"],
"crop_right": settings["crop_right"],
}
# Appliquer le crop 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)
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()
self._status_label.setText(
f"Recadrage applique — {len(settings['pages'])} page(s)"
)
QTimer.singleShot(100, self._viewer.zoom_fit)
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
def on_crop_rejected():
# Revenir au fichier actuel (croppe ou non)
current = self._current_file
self._viewer.load_pdf(current)
self._viewer.go_to_page(cur_page)
self._page_panel.load_thumbnails(self._viewer)
self._crop_dlg = None
self._crop_dlg.accepted.connect(on_crop_accepted)
self._crop_dlg.rejected.connect(on_crop_rejected)
self._crop_dlg.show()
def _do_print(self):
if not self._require_file():
return
try:
from PyQt6.QtPrintSupport import QPrinter, QPrintDialog
from PyQt6.QtGui import QPainter, QImage
import pypdfium2 as pdfium
source = self._result_file or self._current_file
pdf = pdfium.PdfDocument(source)
printer = QPrinter(QPrinter.PrinterMode.HighResolution)
printer.setPageOrientation(
QPageLayout.Orientation.Landscape
if pdf[0].get_size()[0] > pdf[0].get_size()[1]
else QPageLayout.Orientation.Portrait
)
dialog = QPrintDialog(printer, self)
dialog.setWindowTitle("Imprimer — CopyDev Imposing Tool")
if dialog.exec() != QPrintDialog.DialogCode.Accepted:
return
self._show_progress()
self._status_label.setText("Impression en cours...")
QApplication.processEvents()
painter = QPainter()
painter.begin(printer)
for i in range(len(pdf)):
if i > 0:
printer.newPage()
page = pdf[i]
# Rendu a haute resolution
dpi = printer.resolution()
scale = dpi / 72
bitmap = page.render(scale=scale)
pil_image = bitmap.to_pil()
data = pil_image.tobytes("raw", "RGBA")
qimage = QImage(data, pil_image.width, pil_image.height, QImage.Format.Format_RGBA8888)
# Calculer le placement sur la page imprimee
page_rect = printer.pageRect(QPrinter.Unit.Point)
pw, ph = page.get_size()
scale_x = page_rect.width() / pil_image.width
scale_y = page_rect.height() / pil_image.height
s = min(scale_x, scale_y)
target_w = int(pil_image.width * s)
target_h = int(pil_image.height * s)
x = int((page_rect.width() - target_w) / 2)
y = int((page_rect.height() - target_h) / 2)
from PyQt6.QtCore import QRect
painter.drawImage(QRect(x, y, target_w, target_h), qimage)
painter.end()
self._hide_progress()
self._status_label.setText(f"Impression terminee — {len(pdf)} page(s)")
except ImportError:
QMessageBox.critical(self, "Erreur",
"Le module d'impression n'est pas disponible.\n"
"Installez PyQt6 avec le support impression.")
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", f"Erreur d'impression :\n{e}")
def _do_crop_marks(self):
if not self._require_file():
return
dlg = CropMarksDialog(self)
if dlg.exec():
self._show_progress()
settings = dlg.get_settings()
settings["current_page"] = self._viewer.current_page
output = self._make_temp()
try:
add_crop_marks(self._current_file, output, **settings)
self._show_result(output)
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
def _do_remove_crop_marks(self):
if not self._require_file():
return
self._show_progress()
try:
output = self._make_temp()
remove_crop_marks(self._current_file, output)
self._show_result(output)
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
def _do_merge(self):
paths, _ = QFileDialog.getOpenFileNames(self, "Selectionner les PDF a fusionner", "", "PDF (*.pdf)")
if not paths or len(paths) < 2:
if paths and len(paths) == 1:
QMessageBox.information(self, "Fusion", "Selectionnez au moins 2 fichiers PDF.")
return
from pypdf import PdfWriter
self._show_progress()
try:
writer = PdfWriter()
for path in paths:
writer.append(path)
output = self._make_temp()
with open(output, "wb") as f:
writer.write(f)
self._show_result(output)
self._status_label.setText(f"Fusion de {len(paths)} fichiers terminee")
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
# ── A propos ──
# ── Macros ──
def _last_action_info(self):
return getattr(self, '_last_action', None)
def _do_save_macro(self):
from PyQt6.QtWidgets import QInputDialog
name, ok = QInputDialog.getText(self, "Enregistrer la macro", "Nom de la macro :")
if ok and name.strip():
info = self._last_action_info()
if info:
from app.presets.preset_manager import save_preset
save_preset(name.strip(), info["action"], info["settings"])
self._refresh_macros_menu()
self._status_label.setText(f"Macro '{name.strip()}' enregistree")
else:
QMessageBox.information(self, "Macro", "Effectuez d'abord une action a enregistrer.")
def _do_manage_macros(self):
from app.presets.preset_manager import list_presets, delete_preset
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QListWidget, QPushButton, QHBoxLayout
presets = list_presets()
if not presets:
QMessageBox.information(self, "Macros", "Aucune macro enregistree.")
return
dlg = QDialog(self)
dlg.setWindowTitle("Gerer les macros")
dlg.setMinimumWidth(350)
layout = QVBoxLayout(dlg)
lst = QListWidget()
lst.addItems(presets.keys())
layout.addWidget(lst)
bl = QHBoxLayout()
btn_del = QPushButton("Supprimer")
def do_del():
item = lst.currentItem()
if item:
delete_preset(item.text())
lst.takeItem(lst.row(item))
self._refresh_macros_menu()
btn_del.clicked.connect(do_del)
bl.addWidget(btn_del)
btn_close = QPushButton("Fermer")
btn_close.clicked.connect(dlg.close)
bl.addWidget(btn_close)
layout.addLayout(bl)
dlg.exec()
def _refresh_macros_menu(self):
from app.presets.preset_manager import list_presets
# Supprimer les actions apres le separateur
actions = self._macros_menu.actions()
found_sep = False
for act in actions:
if act.isSeparator():
found_sep = True
continue
if found_sep:
self._macros_menu.removeAction(act)
# Ajouter les presets
presets = list_presets()
for name in presets:
self._macros_menu.addAction(name, lambda n=name: self._run_macro(n))
def _run_macro(self, name):
from app.presets.preset_manager import list_presets
presets = list_presets()
preset = presets.get(name)
if not preset or not self._require_file():
return
action = preset["action"]
settings = preset["settings"]
self._show_progress()
output = self._make_temp()
try:
if action == "booklet":
create_booklet(self._current_file, output, **settings)
elif action == "nup":
nup_pages(self._current_file, output, **settings)
self._last_action = {"action": "nup", "settings": settings}
elif action == "step_repeat":
step_and_repeat(self._current_file, output, **settings)
self._last_action = {"action": "step_repeat", "settings": settings}
elif action == "crop":
crop_pages(self._current_file, output, **settings)
elif action == "trim_shift":
trim_and_shift(self._current_file, output, **settings)
self._last_action = {"action": "trim_shift", "settings": settings}
else:
self._hide_progress()
QMessageBox.warning(self, "Macro", f"Action inconnue : {action}")
return
self._show_result(output)
self._status_label.setText(f"Macro '{name}' executee")
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
# ── Pages supplementaires ──
def _do_shuffle_even_odd(self):
"""Separer pages paires et impaires en deux documents."""
if not self._require_file():
return
from pypdf import PdfReader, PdfWriter
reader = PdfReader(self._current_file)
writer_odd = PdfWriter()
writer_even = PdfWriter()
for i, page in enumerate(reader.pages):
if (i + 1) % 2 == 0:
writer_even.add_page(page)
else:
writer_odd.add_page(page)
# Sauvegarder les deux
path_odd, _ = QFileDialog.getSaveFileName(self, "Enregistrer pages impaires", "", "PDF (*.pdf)")
if not path_odd:
return
with open(path_odd, "wb") as f:
writer_odd.write(f)
path_even, _ = QFileDialog.getSaveFileName(self, "Enregistrer pages paires", "", "PDF (*.pdf)")
if path_even:
with open(path_even, "wb") as f:
writer_even.write(f)
self._status_label.setText("Pages paires/impaires separees")
def _do_create_sample(self):
"""Creer un document test numerote."""
from PyQt6.QtWidgets import QInputDialog
num, ok = QInputDialog.getInt(self, "Document test", "Nombre de pages :", 8, 1, 200)
if not ok:
return
from reportlab.pdfgen import canvas as rl_canvas
from reportlab.lib.pagesizes import A4
output = self._make_temp()
c = rl_canvas.Canvas(output, pagesize=A4)
for i in range(1, num + 1):
c.setFont("Helvetica-Bold", 72)
c.drawCentredString(A4[0] / 2, A4[1] / 2, str(i))
c.setFont("Helvetica", 14)
c.drawCentredString(A4[0] / 2, A4[1] / 2 - 40, f"Page {i} sur {num}")
c.setFont("Helvetica", 10)
c.drawCentredString(A4[0] / 2, 30, "CopyDev Imposing Tool — Document test")
c.showPage()
c.save()
self._open_pdf(output)
self._status_label.setText(f"Document test de {num} pages cree")
def _do_check_update(self):
from app.updater import check_for_update, do_update, VERSION
self._status_label.setText("Recherche de mises a jour...")
QApplication.processEvents()
update_available, remote_version = check_for_update()
if update_available:
reply = QMessageBox.question(
self, "Mise a jour disponible",
f"Nouvelle version : {remote_version}\n"
f"Version actuelle : {VERSION}\n\n"
"Mettre a jour maintenant ?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
success, msg = do_update()
if success:
QMessageBox.information(self, "Mise a jour", f"{msg}\n\nRedemarrez l'application.")
else:
QMessageBox.warning(self, "Mise a jour", f"Echec :\n{msg}")
else:
QMessageBox.information(self, "Mise a jour", "Vous etes a jour !")
self._status_label.setText("Pret")
def _show_about(self):
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QDialogButtonBox
from PyQt6.QtGui import QPixmap, QFont
dlg = QDialog(self)
dlg.setWindowTitle("A propos — CopyDev Imposing Tool")
dlg.setFixedSize(520, 480)
dlg.setStyleSheet("QDialog { background-color: #0f172a; }")
layout = QVBoxLayout(dlg)
layout.setContentsMargins(40, 30, 40, 25)
# Logo
logo_label = QLabel()
_base = sys._MEIPASS if getattr(sys, 'frozen', False) else os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
logo_path = os.path.join(_base, "resources", "logo.png")
if os.path.exists(logo_path):
pixmap = QPixmap(logo_path).scaled(150, 150, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
logo_label.setPixmap(pixmap)
logo_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(logo_label)
layout.addSpacing(10)
title = QLabel("COPYDEV IMPOSING TOOL")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
title.setFont(QFont("Segoe UI", 20, QFont.Weight.Bold))
title.setStyleSheet("color: white;")
layout.addWidget(title)
from app.updater import VERSION
version = QLabel(f"Version {VERSION}")
version.setAlignment(Qt.AlignmentFlag.AlignCenter)
version.setFont(QFont("Segoe UI", 10))
version.setStyleSheet("color: #64748b;")
layout.addWidget(version)
layout.addSpacing(15)
desc = QLabel(
"Outil professionnel d'imposition PDF\n"
"Livrets, n-up, repetition, imposition manuelle,\n"
"numerotation, masquage et fonds perdus."
)
desc.setAlignment(Qt.AlignmentFlag.AlignCenter)
desc.setFont(QFont("Segoe UI", 10))
desc.setStyleSheet("color: #94a3b8;")
desc.setWordWrap(True)
layout.addWidget(desc)
layout.addSpacing(20)
sep = QLabel()
sep.setFixedHeight(1)
sep.setStyleSheet("background-color: #1e293b;")
layout.addWidget(sep)
layout.addSpacing(15)
contact = QLabel(
'<p style="text-align:center; line-height: 1.6;">'
'<span style="font-size:14px; font-weight:bold; color:white;">CopyDev</span><br>'
'<span style="color:#94a3b8;">Jules Dubois — Developpeur logiciel</span><br><br>'
'<span style="color:#94a3b8;">Email : </span>'
'<a href="mailto:contact@copydev.fr" style="color:#38bdf8;">contact@copydev.fr</a><br>'
'<span style="color:#94a3b8;">Web : </span>'
'<a href="https://copydev.fr" style="color:#38bdf8;">www.copydev.fr</a>'
'</p>'
)
contact.setAlignment(Qt.AlignmentFlag.AlignCenter)
contact.setOpenExternalLinks(True)
layout.addWidget(contact)
layout.addStretch()
copy_label = QLabel("2026 CopyDev — Tous droits reserves\nLogiciel sous licence — Reproduction interdite")
copy_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
copy_label.setFont(QFont("Segoe UI", 8))
copy_label.setStyleSheet("color: #334155;")
layout.addWidget(copy_label)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
buttons.setStyleSheet("QPushButton { background: #1e293b; color: white; border: 1px solid #334155; padding: 6px 20px; border-radius: 4px; } QPushButton:hover { background: #334155; }")
buttons.rejected.connect(dlg.close)
layout.addWidget(buttons)
dlg.exec()