V2.2.0 — Recadrage + Impression
- Recadrage de pages (Pages > Recadrer) : - Mode marges : retirer X mm de chaque cote - Mode taille finale : definir la taille souhaitee (centrage auto) - Apercu en temps reel de la dimension finale - Appliquer a : page courante, toutes, paires, impaires, plage - Plage de pages personnalisee (ex: 1-3, 5, 8-10) - Impression (Fichier > Imprimer / Ctrl+P) : ouvre le PDF dans le viewer par defaut Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
230
app/dialogs/crop_dialog.py
Normal file
230
app/dialogs/crop_dialog.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"""Dialogue Recadrer les pages."""
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
|
QGroupBox, QDoubleSpinBox, QComboBox, QRadioButton,
|
||||||
|
QDialogButtonBox, QLineEdit)
|
||||||
|
from app.engine.pdf_utils import mm_to_pt, pt_to_mm
|
||||||
|
|
||||||
|
|
||||||
|
class CropDialog(QDialog):
|
||||||
|
def __init__(self, page_width_pt=595, page_height_pt=842, total_pages=1,
|
||||||
|
current_page=0, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Recadrer")
|
||||||
|
self.setMinimumWidth(450)
|
||||||
|
self._page_w = page_width_pt
|
||||||
|
self._page_h = page_height_pt
|
||||||
|
self._total_pages = total_pages
|
||||||
|
self._current_page = current_page
|
||||||
|
self._setup_ui()
|
||||||
|
self._update_preview()
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# Info page actuelle
|
||||||
|
pw_mm = pt_to_mm(self._page_w)
|
||||||
|
ph_mm = pt_to_mm(self._page_h)
|
||||||
|
info = QLabel(f"Page actuelle : {pw_mm:.1f} x {ph_mm:.1f} mm")
|
||||||
|
info.setStyleSheet("color: #888; font-size: 11px;")
|
||||||
|
layout.addWidget(info)
|
||||||
|
|
||||||
|
# Mode
|
||||||
|
grp_mode = QGroupBox("Mode de recadrage")
|
||||||
|
ml = QVBoxLayout(grp_mode)
|
||||||
|
self.rb_margins = QRadioButton("Par marges (retirer de chaque cote)")
|
||||||
|
self.rb_margins.setChecked(True)
|
||||||
|
self.rb_size = QRadioButton("Par taille finale")
|
||||||
|
ml.addWidget(self.rb_margins)
|
||||||
|
ml.addWidget(self.rb_size)
|
||||||
|
layout.addWidget(grp_mode)
|
||||||
|
|
||||||
|
self.rb_margins.toggled.connect(self._on_mode_changed)
|
||||||
|
self.rb_size.toggled.connect(self._on_mode_changed)
|
||||||
|
|
||||||
|
# Marges de recadrage
|
||||||
|
self.grp_margins_input = QGroupBox("Marges a retirer (mm)")
|
||||||
|
mgl = QVBoxLayout(self.grp_margins_input)
|
||||||
|
self.margin_spins = {}
|
||||||
|
row1 = QHBoxLayout()
|
||||||
|
row1.addStretch()
|
||||||
|
row1.addWidget(QLabel("Haut :"))
|
||||||
|
self.margin_spins["haut"] = QDoubleSpinBox()
|
||||||
|
self.margin_spins["haut"].setRange(0, 500)
|
||||||
|
self.margin_spins["haut"].setSuffix(" mm")
|
||||||
|
self.margin_spins["haut"].valueChanged.connect(self._update_preview)
|
||||||
|
row1.addWidget(self.margin_spins["haut"])
|
||||||
|
row1.addStretch()
|
||||||
|
mgl.addLayout(row1)
|
||||||
|
|
||||||
|
row2 = QHBoxLayout()
|
||||||
|
row2.addWidget(QLabel("Gauche :"))
|
||||||
|
self.margin_spins["gauche"] = QDoubleSpinBox()
|
||||||
|
self.margin_spins["gauche"].setRange(0, 500)
|
||||||
|
self.margin_spins["gauche"].setSuffix(" mm")
|
||||||
|
self.margin_spins["gauche"].valueChanged.connect(self._update_preview)
|
||||||
|
row2.addWidget(self.margin_spins["gauche"])
|
||||||
|
row2.addStretch()
|
||||||
|
row2.addWidget(QLabel("Droite :"))
|
||||||
|
self.margin_spins["droite"] = QDoubleSpinBox()
|
||||||
|
self.margin_spins["droite"].setRange(0, 500)
|
||||||
|
self.margin_spins["droite"].setSuffix(" mm")
|
||||||
|
self.margin_spins["droite"].valueChanged.connect(self._update_preview)
|
||||||
|
row2.addWidget(self.margin_spins["droite"])
|
||||||
|
mgl.addLayout(row2)
|
||||||
|
|
||||||
|
row3 = QHBoxLayout()
|
||||||
|
row3.addStretch()
|
||||||
|
row3.addWidget(QLabel("Bas :"))
|
||||||
|
self.margin_spins["bas"] = QDoubleSpinBox()
|
||||||
|
self.margin_spins["bas"].setRange(0, 500)
|
||||||
|
self.margin_spins["bas"].setSuffix(" mm")
|
||||||
|
self.margin_spins["bas"].valueChanged.connect(self._update_preview)
|
||||||
|
row3.addWidget(self.margin_spins["bas"])
|
||||||
|
row3.addStretch()
|
||||||
|
mgl.addLayout(row3)
|
||||||
|
layout.addWidget(self.grp_margins_input)
|
||||||
|
|
||||||
|
# Taille finale
|
||||||
|
self.grp_size_input = QGroupBox("Taille finale (mm)")
|
||||||
|
sgl = QHBoxLayout(self.grp_size_input)
|
||||||
|
sgl.addWidget(QLabel("Largeur :"))
|
||||||
|
self.spin_final_w = QDoubleSpinBox()
|
||||||
|
self.spin_final_w.setRange(10, 5000)
|
||||||
|
self.spin_final_w.setValue(pt_to_mm(self._page_w))
|
||||||
|
self.spin_final_w.setSuffix(" mm")
|
||||||
|
self.spin_final_w.valueChanged.connect(self._update_preview)
|
||||||
|
sgl.addWidget(self.spin_final_w)
|
||||||
|
sgl.addWidget(QLabel("Hauteur :"))
|
||||||
|
self.spin_final_h = QDoubleSpinBox()
|
||||||
|
self.spin_final_h.setRange(10, 5000)
|
||||||
|
self.spin_final_h.setValue(pt_to_mm(self._page_h))
|
||||||
|
self.spin_final_h.setSuffix(" mm")
|
||||||
|
self.spin_final_h.valueChanged.connect(self._update_preview)
|
||||||
|
sgl.addWidget(self.spin_final_h)
|
||||||
|
layout.addWidget(self.grp_size_input)
|
||||||
|
self.grp_size_input.setVisible(False)
|
||||||
|
|
||||||
|
# Apercu dimension finale
|
||||||
|
self.lbl_preview = QLabel()
|
||||||
|
self.lbl_preview.setStyleSheet("font-weight: bold; font-size: 13px; color: #0066cc; padding: 8px;")
|
||||||
|
layout.addWidget(self.lbl_preview)
|
||||||
|
|
||||||
|
# Appliquer a
|
||||||
|
grp_apply = QGroupBox("Appliquer a")
|
||||||
|
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(
|
||||||
|
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||||
|
)
|
||||||
|
buttons.accepted.connect(self.accept)
|
||||||
|
buttons.rejected.connect(self.reject)
|
||||||
|
layout.addWidget(buttons)
|
||||||
|
|
||||||
|
def _on_mode_changed(self):
|
||||||
|
self.grp_margins_input.setVisible(self.rb_margins.isChecked())
|
||||||
|
self.grp_size_input.setVisible(self.rb_size.isChecked())
|
||||||
|
self._update_preview()
|
||||||
|
|
||||||
|
def _update_preview(self):
|
||||||
|
if self.rb_margins.isChecked():
|
||||||
|
top = self.margin_spins["haut"].value()
|
||||||
|
bottom = self.margin_spins["bas"].value()
|
||||||
|
left = self.margin_spins["gauche"].value()
|
||||||
|
right = self.margin_spins["droite"].value()
|
||||||
|
w_mm = pt_to_mm(self._page_w) - left - right
|
||||||
|
h_mm = pt_to_mm(self._page_h) - top - bottom
|
||||||
|
else:
|
||||||
|
w_mm = self.spin_final_w.value()
|
||||||
|
h_mm = self.spin_final_h.value()
|
||||||
|
|
||||||
|
if w_mm <= 0 or h_mm <= 0:
|
||||||
|
self.lbl_preview.setText("Dimensions invalides !")
|
||||||
|
self.lbl_preview.setStyleSheet("font-weight: bold; font-size: 13px; color: red; padding: 8px;")
|
||||||
|
else:
|
||||||
|
self.lbl_preview.setText(f"Resultat : {w_mm:.1f} x {h_mm:.1f} mm")
|
||||||
|
self.lbl_preview.setStyleSheet("font-weight: bold; font-size: 13px; color: #0066cc; padding: 8px;")
|
||||||
|
|
||||||
|
def _parse_range(self):
|
||||||
|
"""Parser la plage de pages. Retourne une liste d'indices (0-based)."""
|
||||||
|
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 get_settings(self):
|
||||||
|
# Mode
|
||||||
|
if self.rb_margins.isChecked():
|
||||||
|
mode = "margins"
|
||||||
|
crop_top = mm_to_pt(self.margin_spins["haut"].value())
|
||||||
|
crop_bottom = mm_to_pt(self.margin_spins["bas"].value())
|
||||||
|
crop_left = mm_to_pt(self.margin_spins["gauche"].value())
|
||||||
|
crop_right = mm_to_pt(self.margin_spins["droite"].value())
|
||||||
|
else:
|
||||||
|
mode = "size"
|
||||||
|
final_w = mm_to_pt(self.spin_final_w.value())
|
||||||
|
final_h = mm_to_pt(self.spin_final_h.value())
|
||||||
|
crop_left = (self._page_w - final_w) / 2
|
||||||
|
crop_right = crop_left
|
||||||
|
crop_top = (self._page_h - final_h) / 2
|
||||||
|
crop_bottom = crop_top
|
||||||
|
|
||||||
|
# Pages cibles
|
||||||
|
if self.rb_current.isChecked():
|
||||||
|
pages = [self._current_page]
|
||||||
|
elif self.rb_all.isChecked():
|
||||||
|
pages = list(range(self._total_pages))
|
||||||
|
elif self.rb_even.isChecked():
|
||||||
|
pages = [i for i in range(self._total_pages) if (i + 1) % 2 == 0]
|
||||||
|
elif self.rb_odd.isChecked():
|
||||||
|
pages = [i for i in range(self._total_pages) if (i + 1) % 2 != 0]
|
||||||
|
else:
|
||||||
|
pages = self._parse_range()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"crop_top": crop_top,
|
||||||
|
"crop_bottom": crop_bottom,
|
||||||
|
"crop_left": crop_left,
|
||||||
|
"crop_right": crop_right,
|
||||||
|
"pages": pages,
|
||||||
|
}
|
||||||
43
app/engine/crop.py
Normal file
43
app/engine/crop.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Moteur Recadrage de pages."""
|
||||||
|
|
||||||
|
from pypdf import PdfReader, PdfWriter
|
||||||
|
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):
|
||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
reader = PdfReader(input_path)
|
||||||
|
writer = PdfWriter()
|
||||||
|
|
||||||
|
if pages 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
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
writer.write(f)
|
||||||
|
return output_path
|
||||||
@@ -23,6 +23,7 @@ from app.dialogs.page_numbers_dialog import PageNumbersDialog
|
|||||||
from app.dialogs.masking_dialog import MaskingDialog
|
from app.dialogs.masking_dialog import MaskingDialog
|
||||||
from app.dialogs.bleeds_dialog import BleedsDialog
|
from app.dialogs.bleeds_dialog import BleedsDialog
|
||||||
from app.dialogs.crop_marks_dialog import CropMarksDialog
|
from app.dialogs.crop_marks_dialog import CropMarksDialog
|
||||||
|
from app.dialogs.crop_dialog import CropDialog
|
||||||
|
|
||||||
from app.engine.booklet import create_booklet
|
from app.engine.booklet import create_booklet
|
||||||
from app.engine.nup import nup_pages
|
from app.engine.nup import nup_pages
|
||||||
@@ -34,6 +35,7 @@ from app.engine.page_numbers import add_page_numbers
|
|||||||
from app.engine.masking import apply_masking
|
from app.engine.masking import apply_masking
|
||||||
from app.engine.bleeds import define_bleeds
|
from app.engine.bleeds import define_bleeds
|
||||||
from app.engine.crop_marks import add_crop_marks, remove_crop_marks
|
from app.engine.crop_marks import add_crop_marks, remove_crop_marks
|
||||||
|
from app.engine.crop import crop_pages
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
@@ -100,6 +102,7 @@ class MainWindow(QMainWindow):
|
|||||||
file_menu = menubar.addMenu("Fichier")
|
file_menu = menubar.addMenu("Fichier")
|
||||||
self._add_action(file_menu, "Ouvrir PDF...", "Ctrl+O", self._open_file)
|
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, "Enregistrer le resultat...", "Ctrl+S", self._save_result)
|
||||||
|
self._add_action(file_menu, "Imprimer...", "Ctrl+P", self._do_print)
|
||||||
file_menu.addSeparator()
|
file_menu.addSeparator()
|
||||||
self._add_action(file_menu, "Fusionner des PDF...", "Ctrl+M", self._do_merge)
|
self._add_action(file_menu, "Fusionner des PDF...", "Ctrl+M", self._do_merge)
|
||||||
file_menu.addSeparator()
|
file_menu.addSeparator()
|
||||||
@@ -115,6 +118,8 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# Pages
|
# Pages
|
||||||
pages_menu = menubar.addMenu("Pages")
|
pages_menu = menubar.addMenu("Pages")
|
||||||
|
self._add_action(pages_menu, "Recadrer...", None, self._do_crop)
|
||||||
|
pages_menu.addSeparator()
|
||||||
self._add_action(pages_menu, "Inserer des pages blanches", None, self._do_insert_blank)
|
self._add_action(pages_menu, "Inserer des pages blanches", None, self._do_insert_blank)
|
||||||
|
|
||||||
# Annotations
|
# Annotations
|
||||||
@@ -494,6 +499,42 @@ class MainWindow(QMainWindow):
|
|||||||
self._hide_progress()
|
self._hide_progress()
|
||||||
QMessageBox.critical(self, "Erreur", str(e))
|
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
|
||||||
|
reader = PdfReader(self._current_file)
|
||||||
|
page = reader.pages[self._viewer.current_page]
|
||||||
|
w, h = get_page_size(page)
|
||||||
|
dlg = CropDialog(
|
||||||
|
page_width_pt=w, page_height_pt=h,
|
||||||
|
total_pages=len(reader.pages),
|
||||||
|
current_page=self._viewer.current_page,
|
||||||
|
parent=self
|
||||||
|
)
|
||||||
|
if dlg.exec():
|
||||||
|
self._show_progress()
|
||||||
|
settings = dlg.get_settings()
|
||||||
|
output = self._make_temp()
|
||||||
|
try:
|
||||||
|
crop_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_print(self):
|
||||||
|
if not self._require_file():
|
||||||
|
return
|
||||||
|
import subprocess
|
||||||
|
try:
|
||||||
|
source = self._result_file or self._current_file
|
||||||
|
subprocess.Popen(["cmd", "/c", "start", "", source], shell=True)
|
||||||
|
self._status_label.setText("Ouverture pour impression...")
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Erreur", f"Impossible d'imprimer :\n{e}")
|
||||||
|
|
||||||
def _do_crop_marks(self):
|
def _do_crop_marks(self):
|
||||||
if not self._require_file():
|
if not self._require_file():
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ REPO = "jules/copydev-imposing-tool"
|
|||||||
BRANCH = "master"
|
BRANCH = "master"
|
||||||
|
|
||||||
# Version actuelle (incrementee a chaque release)
|
# Version actuelle (incrementee a chaque release)
|
||||||
VERSION = "2.1.0"
|
VERSION = "2.2.0"
|
||||||
|
|
||||||
|
|
||||||
def get_remote_version():
|
def get_remote_version():
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2.1.0
|
2.2.0
|
||||||
|
|||||||
Reference in New Issue
Block a user