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

@@ -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():