V3.0.0 — Rotation, Annuler/Refaire, Delivretiser, Trim&Shift, Inverser

- Rotation de pages : clic droit sur vignette → Pivoter +90/-90/180
  Aussi dans le menu Pages
- Ctrl+Z / Ctrl+Y : annuler et refaire (pile de 20 etats)
- Delivretiser : coupe les pages en 2 et remet dans l'ordre sequentiel
  (inverse de la creation de livret)
- Trim & Shift : decaler le contenu des pages
  - Decalage X/Y different pages paires/impaires
  - Compensation de creep progressive pour livrets epais
- Inverser l'ordre des pages (menu Pages)
- Fix impression : ouvre le PDF dans le lecteur par defaut
- Barre outils droite : ajout Delivretiser, Trim/Shift, Inverser

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jules
2026-03-21 16:02:07 +01:00
parent cee86f5c03
commit 673d76caa2
7 changed files with 364 additions and 12 deletions

View File

@@ -0,0 +1,84 @@
"""Dialogue Trim & Shift."""
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QGroupBox, QDoubleSpinBox, QCheckBox,
QDialogButtonBox)
from app.engine.pdf_utils import mm_to_pt
class TrimShiftDialog(QDialog):
def __init__(self, total_pages=1, parent=None):
super().__init__(parent)
self.setWindowTitle("Trim && Shift — Decaler le contenu")
self.setMinimumWidth(420)
self._total_pages = total_pages
self._setup_ui()
def _setup_ui(self):
layout = QVBoxLayout(self)
# Decalage pages impaires
grp_odd = QGroupBox("Decalage pages impaires (mm)")
ol = QHBoxLayout(grp_odd)
ol.addWidget(QLabel("X :"))
self.spin_x = QDoubleSpinBox()
self.spin_x.setRange(-200, 200)
self.spin_x.setSuffix(" mm")
ol.addWidget(self.spin_x)
ol.addWidget(QLabel("Y :"))
self.spin_y = QDoubleSpinBox()
self.spin_y.setRange(-200, 200)
self.spin_y.setSuffix(" mm")
ol.addWidget(self.spin_y)
layout.addWidget(grp_odd)
# Pages paires differentes
self.chk_different = QCheckBox("Decalage different pour pages paires")
layout.addWidget(self.chk_different)
grp_even = QGroupBox("Decalage pages paires (mm)")
el = QHBoxLayout(grp_even)
el.addWidget(QLabel("X :"))
self.spin_x_even = QDoubleSpinBox()
self.spin_x_even.setRange(-200, 200)
self.spin_x_even.setSuffix(" mm")
self.spin_x_even.setEnabled(False)
el.addWidget(self.spin_x_even)
el.addWidget(QLabel("Y :"))
self.spin_y_even = QDoubleSpinBox()
self.spin_y_even.setRange(-200, 200)
self.spin_y_even.setSuffix(" mm")
self.spin_y_even.setEnabled(False)
el.addWidget(self.spin_y_even)
layout.addWidget(grp_even)
self.chk_different.toggled.connect(self.spin_x_even.setEnabled)
self.chk_different.toggled.connect(self.spin_y_even.setEnabled)
# Creep
grp_creep = QGroupBox("Compensation de creep (livrets)")
cl = QHBoxLayout(grp_creep)
cl.addWidget(QLabel("Creep max :"))
self.spin_creep = QDoubleSpinBox()
self.spin_creep.setRange(0, 20)
self.spin_creep.setSuffix(" mm")
self.spin_creep.setToolTip("Decalage progressif pour compenser l'epaisseur du papier")
cl.addWidget(self.spin_creep)
layout.addWidget(grp_creep)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def get_settings(self):
return {
"shift_x": mm_to_pt(self.spin_x.value()),
"shift_y": mm_to_pt(self.spin_y.value()),
"shift_x_even": mm_to_pt(self.spin_x_even.value()) if self.chk_different.isChecked() else None,
"shift_y_even": mm_to_pt(self.spin_y_even.value()) if self.chk_different.isChecked() else None,
"creep": mm_to_pt(self.spin_creep.value()),
"creep_pages": self._total_pages,
}

59
app/engine/trim_shift.py Normal file
View File

@@ -0,0 +1,59 @@
"""Moteur Trim & Shift — decaler le contenu des pages."""
from pypdf import PdfReader, PdfWriter, PageObject, Transformation
from .pdf_utils import get_page_size
def trim_and_shift(input_path, output_path, shift_x=0, shift_y=0,
shift_x_even=None, shift_y_even=None,
creep=0, creep_pages=0):
"""Decaler le contenu des pages.
shift_x, shift_y: decalage en points (pages impaires si even specifie)
shift_x_even, shift_y_even: decalage pour pages paires (None = meme que impaires)
creep: decalage progressif en points (compensation epaisseur papier)
creep_pages: nombre total de pages pour le calcul du creep
"""
reader = PdfReader(input_path)
writer = PdfWriter()
num_pages = len(reader.pages)
if shift_x_even is None:
shift_x_even = shift_x
if shift_y_even is None:
shift_y_even = shift_y
for i, page in enumerate(reader.pages):
page_num = i + 1
w, h = get_page_size(page)
# Decalage de base
if page_num % 2 == 0:
dx, dy = shift_x_even, shift_y_even
else:
dx, dy = shift_x, shift_y
# Creep progressif (pour livrets)
if creep != 0 and creep_pages > 0:
# Les pages du centre se decalent le plus
mid = num_pages / 2
distance_from_center = abs(i - mid) / mid # 0 au centre, 1 aux bords
creep_amount = creep * (1 - distance_from_center)
if page_num % 2 == 0:
dx -= creep_amount # pages paires : decaler vers la gauche
else:
dx += creep_amount # pages impaires : decaler vers la droite
if dx != 0 or dy != 0:
new_page = PageObject.create_blank_page(width=w, height=h)
overlay = PageObject.create_blank_page(width=w, height=h)
overlay.merge_page(page)
overlay.add_transformation(Transformation().translate(dx, dy))
new_page.merge_page(overlay)
writer.add_page(new_page)
else:
writer.add_page(page)
with open(output_path, "wb") as f:
writer.write(f)
return output_path

73
app/engine/unbooklet.py Normal file
View File

@@ -0,0 +1,73 @@
"""Moteur Delivretiser — convertir un livret en pages sequentielles."""
from pypdf import PdfReader, PdfWriter, PageObject, Transformation
from .pdf_utils import get_page_size
import math
def unbooklet(input_path, output_path):
"""Delivretiser un PDF en format livret.
Chaque page du livret contient 2 pages cote a cote.
On les coupe en 2 et on les remet dans l'ordre sequentiel.
"""
reader = PdfReader(input_path)
num_sheets = len(reader.pages)
total_pages = num_sheets * 2
# Extraire toutes les demi-pages
half_pages = [] # (sheet_index, "left" ou "right")
for i in range(num_sheets):
half_pages.append((i, "left"))
half_pages.append((i, "right"))
# Ordre du livret (piqure a cheval) :
# Feuille 0 recto : page N, page 1
# Feuille 0 verso : page 2, page N-1
# Feuille 1 recto : page N-2, page 3
# etc.
# On reconstruit l'ordre
page_map = {} # page_number (1-based) -> (sheet_index, side)
n = total_pages
for i in range(num_sheets):
if i % 2 == 0:
# Recto : gauche = N - i, droite = i + 1
page_map[n - i] = (i, "left")
page_map[i + 1] = (i, "right")
else:
# Verso : gauche = i + 1, droite = N - i
page_map[i + 1] = (i, "left")
page_map[n - i] = (i, "right")
writer = PdfWriter()
for page_num in range(1, total_pages + 1):
if page_num not in page_map:
continue
sheet_idx, side = page_map[page_num]
sheet = reader.pages[sheet_idx]
sheet_w, sheet_h = get_page_size(sheet)
half_w = sheet_w / 2
# Creer une nouvelle page avec la moitie
new_page = PageObject.create_blank_page(width=half_w, height=sheet_h)
if side == "left":
# Copier la moitie gauche
overlay = PageObject.create_blank_page(width=half_w, height=sheet_h)
overlay.merge_page(sheet)
overlay.mediabox.upper_right = (half_w, sheet_h)
new_page.merge_page(overlay)
else:
# Copier la moitie droite
overlay = PageObject.create_blank_page(width=half_w, height=sheet_h)
overlay.merge_page(sheet)
overlay.add_transformation(Transformation().translate(-half_w, 0))
overlay.mediabox.upper_right = (half_w, sheet_h)
new_page.merge_page(overlay)
writer.add_page(new_page)
with open(output_path, "wb") as f:
writer.write(f)
return output_path

View File

@@ -39,6 +39,9 @@ from app.engine.bleeds import define_bleeds
from app.engine.crop_marks import add_crop_marks, remove_crop_marks
from app.engine.crop import crop_pages
from app.engine.add_content import add_text_to_pages, add_image_to_pages
from app.engine.unbooklet import unbooklet
from app.engine.trim_shift import trim_and_shift
from app.dialogs.trim_shift_dialog import TrimShiftDialog
class MainWindow(QMainWindow):
@@ -56,8 +59,10 @@ class MainWindow(QMainWindow):
self._current_file = None
self._result_file = None
self._original_file = None # fichier avant crop (pour recadrage non-destructif)
self._crop_settings = {} # {page_index: {top, bottom, left, right} en pt}
self._original_file = None
self._crop_settings = {}
self._undo_stack = [] # pile de fichiers pour annuler
self._redo_stack = []
# Drag & drop
self.setAcceptDrops(True)
@@ -200,6 +205,11 @@ class MainWindow(QMainWindow):
file_menu.addSeparator()
self._add_action(file_menu, "Quitter", "Ctrl+Q", self.close)
# Edition
edit_menu = menubar.addMenu("Edition")
self._add_action(edit_menu, "Annuler", "Ctrl+Z", self._do_undo)
self._add_action(edit_menu, "Refaire", "Ctrl+Y", self._do_redo)
# Imposition
impose_menu = menubar.addMenu("Imposition")
self._add_action(impose_menu, "Creer un livret", None, self._do_booklet)
@@ -207,10 +217,18 @@ class MainWindow(QMainWindow):
self._add_action(impose_menu, "Repetition (Step && Repeat)", None, self._do_step_repeat)
self._add_action(impose_menu, "Imposition manuelle", None, self._do_manual_impose)
self._add_action(impose_menu, "Joindre deux pages", None, self._do_join_pages)
impose_menu.addSeparator()
self._add_action(impose_menu, "Delivretiser", None, self._do_unbooklet)
# Pages
pages_menu = menubar.addMenu("Pages")
self._add_action(pages_menu, "Recadrer...", None, self._do_crop)
self._add_action(pages_menu, "Trim && Shift...", None, self._do_trim_shift)
pages_menu.addSeparator()
self._add_action(pages_menu, "Pivoter +90", None, lambda: self._rotate_current(90))
self._add_action(pages_menu, "Pivoter -90", None, lambda: self._rotate_current(-90))
self._add_action(pages_menu, "Pivoter 180", None, lambda: self._rotate_current(180))
self._add_action(pages_menu, "Inverser l'ordre des pages", None, self._do_reverse_pages)
pages_menu.addSeparator()
self._add_action(pages_menu, "Inserer des pages blanches", None, self._do_insert_blank)
@@ -284,6 +302,7 @@ class MainWindow(QMainWindow):
# Section Imposition
rtb.addWidget(self._make_section_label("IMPOSITION"))
rtb.addAction("Livret", self._do_booklet)
rtb.addAction("Delivretiser", self._do_unbooklet)
rtb.addAction("N-Up", self._do_nup)
rtb.addAction("Repetition", self._do_step_repeat)
rtb.addAction("Manuel", self._do_manual_impose)
@@ -293,7 +312,9 @@ class MainWindow(QMainWindow):
# Section Pages
rtb.addWidget(self._make_section_label("PAGES"))
rtb.addAction("Recadrer", self._do_crop)
rtb.addAction("Trim/Shift", self._do_trim_shift)
rtb.addAction("Pg blanche", self._do_insert_blank)
rtb.addAction("Inverser", self._do_reverse_pages)
rtb.addAction("Fusionner", self._do_merge)
rtb.addSeparator()
@@ -340,6 +361,7 @@ class MainWindow(QMainWindow):
self._page_panel.page_delete_requested.connect(self._on_page_delete)
self._page_panel.page_insert_requested.connect(self._on_page_insert)
self._page_panel.pages_reordered.connect(self._on_pages_reordered)
self._page_panel.page_rotate_requested.connect(self._on_page_rotate)
splitter.addWidget(self._page_panel)
# Viewer PDF
@@ -488,6 +510,12 @@ class MainWindow(QMainWindow):
self._progress.setRange(0, 1)
def _show_result(self, output_path):
# Sauvegarder l'etat actuel pour annulation
if self._current_file:
self._undo_stack.append(self._current_file)
self._redo_stack.clear()
if len(self._undo_stack) > 20:
self._undo_stack.pop(0)
self._result_file = output_path
self._current_file = output_path
self._viewer.load_pdf(output_path)
@@ -496,6 +524,97 @@ class MainWindow(QMainWindow):
self._status_label.setText("Traitement termine")
QTimer.singleShot(100, self._viewer.zoom_fit)
def _do_undo(self):
if not self._undo_stack:
return
self._redo_stack.append(self._current_file)
prev = self._undo_stack.pop()
self._current_file = prev
self._result_file = prev
self._viewer.load_pdf(prev)
self._page_panel.load_thumbnails(self._viewer)
self._status_label.setText("Annule")
QTimer.singleShot(100, self._viewer.zoom_fit)
def _do_redo(self):
if not self._redo_stack:
return
self._undo_stack.append(self._current_file)
nxt = self._redo_stack.pop()
self._current_file = nxt
self._result_file = nxt
self._viewer.load_pdf(nxt)
self._page_panel.load_thumbnails(self._viewer)
self._status_label.setText("Refait")
QTimer.singleShot(100, self._viewer.zoom_fit)
# ── Rotation ──
def _on_page_rotate(self, page_index, degrees):
if not self._current_file:
return
from pypdf import PdfReader, PdfWriter
reader = PdfReader(self._current_file)
writer = PdfWriter()
for i, page in enumerate(reader.pages):
if i == page_index:
page = page.rotate(degrees)
writer.add_page(page)
output = self._make_temp()
with open(output, "wb") as f:
writer.write(f)
self._show_result(output)
def _rotate_current(self, degrees):
if not self._require_file():
return
self._on_page_rotate(self._viewer.current_page, degrees)
def _do_reverse_pages(self):
if not self._require_file():
return
from pypdf import PdfReader, PdfWriter
self._show_progress()
reader = PdfReader(self._current_file)
writer = PdfWriter()
for page in reversed(reader.pages):
writer.add_page(page)
output = self._make_temp()
with open(output, "wb") as f:
writer.write(f)
self._show_result(output)
self._status_label.setText("Ordre des pages inverse")
def _do_unbooklet(self):
if not self._require_file():
return
self._show_progress()
output = self._make_temp()
try:
unbooklet(self._current_file, output)
self._show_result(output)
self._status_label.setText("Delivretisation terminee")
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
def _do_trim_shift(self):
if not self._require_file():
return
from pypdf import PdfReader
reader = PdfReader(self._current_file)
dlg = TrimShiftDialog(total_pages=len(reader.pages), parent=self)
if dlg.exec():
self._show_progress()
settings = dlg.get_settings()
output = self._make_temp()
try:
trim_and_shift(self._current_file, output, **settings)
self._show_result(output)
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
# ── Actions imposition ──
def _do_booklet(self):
@@ -789,14 +908,20 @@ class MainWindow(QMainWindow):
return
try:
source = self._result_file or self._current_file
# Copier dans un fichier temp avec un nom lisible
import shutil
tmp_print = os.path.join(tempfile.gettempdir(), "copydev_print.pdf")
shutil.copy2(source, tmp_print)
if sys.platform == "win32":
os.startfile(source, "print")
import subprocess
# Ouvrir avec le lecteur PDF par defaut (qui a un menu imprimer)
subprocess.Popen(["cmd", "/c", "start", "", tmp_print])
else:
import subprocess
subprocess.Popen(["lp", source])
self._status_label.setText("Impression lancee...")
subprocess.Popen(["xdg-open", tmp_print])
self._status_label.setText("Fichier ouvert pour impression")
except Exception as e:
QMessageBox.critical(self, "Erreur", f"Impossible d'imprimer :\n{e}")
QMessageBox.critical(self, "Erreur", f"Impossible d'ouvrir pour impression :\n{e}")
def _do_crop_marks(self):
if not self._require_file():

View File

@@ -9,10 +9,11 @@ from PyQt6.QtCore import Qt, pyqtSignal, QSize
class PagePanel(QWidget):
"""Panneau de vignettes avec drag & drop, suppression, insertion."""
page_selected = pyqtSignal(int) # page index cliquee
pages_reordered = pyqtSignal(list) # nouvelle liste d'indices
page_delete_requested = pyqtSignal(int) # demande suppression
page_insert_requested = pyqtSignal(int) # demande insertion page blanche apres
page_selected = pyqtSignal(int)
pages_reordered = pyqtSignal(list)
page_delete_requested = pyqtSignal(int)
page_insert_requested = pyqtSignal(int)
page_rotate_requested = pyqtSignal(int, int) # page_index, degrees (90, -90, 180)
def __init__(self, parent=None):
super().__init__(parent)
@@ -106,6 +107,10 @@ class PagePanel(QWidget):
QMenu::item:selected { background: #e0f4ff; color: #006fa6; }
""")
act_rot_right = menu.addAction("Pivoter +90°")
act_rot_left = menu.addAction("Pivoter -90°")
act_rot_180 = menu.addAction("Pivoter 180°")
menu.addSeparator()
act_insert = menu.addAction("Inserer une page blanche apres")
act_delete = menu.addAction("Supprimer cette page")
@@ -114,3 +119,9 @@ class PagePanel(QWidget):
self.page_delete_requested.emit(row)
elif action == act_insert:
self.page_insert_requested.emit(row)
elif action == act_rot_right:
self.page_rotate_requested.emit(row, 90)
elif action == act_rot_left:
self.page_rotate_requested.emit(row, -90)
elif action == act_rot_180:
self.page_rotate_requested.emit(row, 180)

View File

@@ -14,7 +14,7 @@ REPO = "jules/copydev-imposing-tool"
BRANCH = "master"
# Version actuelle (incrementee a chaque release)
VERSION = "2.6.0"
VERSION = "3.0.0"
def get_remote_version():