Initial commit — Copydev Imposing Tool
Outil d'imposition PDF desktop (PyQt6) remplacant Quite Imposing Plus. 9 fonctions : livret, n-up, step & repeat, imposition manuelle, jointure pages, pages blanches, numerotation, masquage, fonds perdus. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
dist/
|
||||
build/output/
|
||||
*.spec.bak
|
||||
venv/
|
||||
.venv/
|
||||
*.egg-info/
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/dialogs/__init__.py
Normal file
0
app/dialogs/__init__.py
Normal file
53
app/dialogs/bleeds_dialog.py
Normal file
53
app/dialogs/bleeds_dialog.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Dialogue Definition des fonds perdus."""
|
||||
|
||||
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QGroupBox, QDoubleSpinBox, QDialogButtonBox)
|
||||
|
||||
|
||||
class BleedsDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Fonds perdus (Bleeds)")
|
||||
self.setMinimumWidth(350)
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
layout.addWidget(QLabel(
|
||||
"Definir la zone de fond perdu autour de chaque page.\n"
|
||||
"Cette zone sera prise en compte par les fonctions N-Up et Repetition."
|
||||
))
|
||||
|
||||
grp = QGroupBox("Fonds perdus (mm)")
|
||||
gl = QVBoxLayout(grp)
|
||||
|
||||
self.spins = {}
|
||||
for label in ["Haut", "Bas", "Gauche", "Droite"]:
|
||||
hl = QHBoxLayout()
|
||||
hl.addWidget(QLabel(f"{label} :"))
|
||||
spin = QDoubleSpinBox()
|
||||
spin.setRange(0, 50)
|
||||
spin.setValue(3)
|
||||
spin.setSuffix(" mm")
|
||||
hl.addWidget(spin)
|
||||
hl.addStretch()
|
||||
gl.addLayout(hl)
|
||||
self.spins[label.lower()] = spin
|
||||
|
||||
layout.addWidget(grp)
|
||||
|
||||
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 {
|
||||
"bleed_top": self.spins["haut"].value(),
|
||||
"bleed_bottom": self.spins["bas"].value(),
|
||||
"bleed_left": self.spins["gauche"].value(),
|
||||
"bleed_right": self.spins["droite"].value(),
|
||||
}
|
||||
104
app/dialogs/booklet_dialog.py
Normal file
104
app/dialogs/booklet_dialog.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Dialogue Creer un livret."""
|
||||
|
||||
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QComboBox, QGroupBox, QRadioButton, QSpinBox,
|
||||
QDialogButtonBox, QDoubleSpinBox)
|
||||
from app.engine.pdf_utils import PAGE_SIZES
|
||||
|
||||
|
||||
class BookletDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Creer un livret")
|
||||
self.setMinimumWidth(400)
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Type de reliure
|
||||
grp_binding = QGroupBox("Type de reliure")
|
||||
bl = QVBoxLayout(grp_binding)
|
||||
self.rb_saddle = QRadioButton("Piqure a cheval (livret agrafe)")
|
||||
self.rb_saddle.setChecked(True)
|
||||
self.rb_perfect = QRadioButton("Dos carre colle (livre)")
|
||||
self.rb_continuous = QRadioButton("Continu (pages sequentielles)")
|
||||
self.rb_cut_stacks = QRadioButton("Cut stacks (couper et empiler)")
|
||||
bl.addWidget(self.rb_saddle)
|
||||
bl.addWidget(self.rb_perfect)
|
||||
bl.addWidget(self.rb_continuous)
|
||||
bl.addWidget(self.rb_cut_stacks)
|
||||
layout.addWidget(grp_binding)
|
||||
|
||||
# Taille signature (perfect bound)
|
||||
sig_layout = QHBoxLayout()
|
||||
sig_layout.addWidget(QLabel("Pages par cahier :"))
|
||||
self.spin_signature = QSpinBox()
|
||||
self.spin_signature.setRange(4, 128)
|
||||
self.spin_signature.setValue(32)
|
||||
self.spin_signature.setSingleStep(4)
|
||||
self.spin_signature.setEnabled(False)
|
||||
sig_layout.addWidget(self.spin_signature)
|
||||
sig_layout.addStretch()
|
||||
layout.addLayout(sig_layout)
|
||||
|
||||
self.rb_perfect.toggled.connect(self.spin_signature.setEnabled)
|
||||
|
||||
# Taille de feuille
|
||||
grp_sheet = QGroupBox("Taille de la feuille")
|
||||
sl = QHBoxLayout(grp_sheet)
|
||||
sl.addWidget(QLabel("Format :"))
|
||||
self.combo_sheet = QComboBox()
|
||||
self.combo_sheet.addItem("Automatique (2x page)")
|
||||
for name in PAGE_SIZES:
|
||||
self.combo_sheet.addItem(name)
|
||||
sl.addWidget(self.combo_sheet)
|
||||
layout.addWidget(grp_sheet)
|
||||
|
||||
# Alignement
|
||||
grp_align = QGroupBox("Alignement")
|
||||
al = QVBoxLayout(grp_align)
|
||||
self.rb_center = QRadioButton("Centrer")
|
||||
self.rb_center.setChecked(True)
|
||||
self.rb_pull = QRadioButton("Vers le dos (centre)")
|
||||
self.rb_bl = QRadioButton("Bas-gauche")
|
||||
al.addWidget(self.rb_center)
|
||||
al.addWidget(self.rb_pull)
|
||||
al.addWidget(self.rb_bl)
|
||||
layout.addWidget(grp_align)
|
||||
|
||||
# Boutons OK/Annuler
|
||||
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):
|
||||
"""Retourner les parametres selectionnes."""
|
||||
if self.rb_saddle.isChecked():
|
||||
binding = "saddle_stitch"
|
||||
elif self.rb_perfect.isChecked():
|
||||
binding = "perfect_bound"
|
||||
elif self.rb_continuous.isChecked():
|
||||
binding = "continuous"
|
||||
else:
|
||||
binding = "cut_stacks"
|
||||
|
||||
sheet_text = self.combo_sheet.currentText()
|
||||
sheet_size = PAGE_SIZES.get(sheet_text, None)
|
||||
|
||||
if self.rb_center.isChecked():
|
||||
alignment = "center"
|
||||
elif self.rb_pull.isChecked():
|
||||
alignment = "pull_center"
|
||||
else:
|
||||
alignment = "bottom_left"
|
||||
|
||||
return {
|
||||
"binding": binding,
|
||||
"sheet_size": sheet_size,
|
||||
"signature_size": self.spin_signature.value(),
|
||||
"alignment": alignment,
|
||||
}
|
||||
82
app/dialogs/insert_blank_dialog.py
Normal file
82
app/dialogs/insert_blank_dialog.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Dialogue Inserer des pages blanches."""
|
||||
|
||||
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QGroupBox, QRadioButton, QSpinBox,
|
||||
QDialogButtonBox)
|
||||
|
||||
|
||||
class InsertBlankDialog(QDialog):
|
||||
def __init__(self, total_pages=1, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Inserer des pages blanches")
|
||||
self.setMinimumWidth(400)
|
||||
self._total_pages = total_pages
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
grp = QGroupBox("Mode d'insertion")
|
||||
gl = QVBoxLayout(grp)
|
||||
|
||||
self.rb_after = QRadioButton("Apres la page :")
|
||||
self.rb_after.setChecked(True)
|
||||
after_layout = QHBoxLayout()
|
||||
after_layout.addWidget(self.rb_after)
|
||||
self.spin_pos = QSpinBox()
|
||||
self.spin_pos.setRange(1, self._total_pages)
|
||||
self.spin_pos.setValue(1)
|
||||
after_layout.addWidget(self.spin_pos)
|
||||
after_layout.addStretch()
|
||||
gl.addLayout(after_layout)
|
||||
|
||||
self.rb_before = QRadioButton("Avant la page selectionnee")
|
||||
gl.addWidget(self.rb_before)
|
||||
|
||||
every_layout = QHBoxLayout()
|
||||
self.rb_every = QRadioButton("Toutes les")
|
||||
every_layout.addWidget(self.rb_every)
|
||||
self.spin_every = QSpinBox()
|
||||
self.spin_every.setRange(1, 100)
|
||||
self.spin_every.setValue(2)
|
||||
self.spin_every.setEnabled(False)
|
||||
every_layout.addWidget(self.spin_every)
|
||||
every_layout.addWidget(QLabel("pages"))
|
||||
every_layout.addStretch()
|
||||
gl.addLayout(every_layout)
|
||||
|
||||
pad_layout = QHBoxLayout()
|
||||
self.rb_pad = QRadioButton("Completer au multiple de")
|
||||
pad_layout.addWidget(self.rb_pad)
|
||||
self.spin_multiple = QSpinBox()
|
||||
self.spin_multiple.setRange(2, 32)
|
||||
self.spin_multiple.setValue(4)
|
||||
self.spin_multiple.setEnabled(False)
|
||||
pad_layout.addWidget(self.spin_multiple)
|
||||
pad_layout.addWidget(QLabel("pages"))
|
||||
pad_layout.addStretch()
|
||||
gl.addLayout(pad_layout)
|
||||
|
||||
layout.addWidget(grp)
|
||||
|
||||
# Activer/desactiver les spinbox
|
||||
self.rb_every.toggled.connect(self.spin_every.setEnabled)
|
||||
self.rb_pad.toggled.connect(self.spin_multiple.setEnabled)
|
||||
|
||||
# Boutons
|
||||
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):
|
||||
if self.rb_after.isChecked():
|
||||
return {"mode": "after", "position": self.spin_pos.value()}
|
||||
elif self.rb_before.isChecked():
|
||||
return {"mode": "before", "position": self.spin_pos.value()}
|
||||
elif self.rb_every.isChecked():
|
||||
return {"mode": "every_n", "every_n": self.spin_every.value()}
|
||||
else:
|
||||
return {"mode": "pad_to_multiple", "target_multiple": self.spin_multiple.value()}
|
||||
38
app/dialogs/join_pages_dialog.py
Normal file
38
app/dialogs/join_pages_dialog.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Dialogue Joindre deux pages."""
|
||||
|
||||
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QLabel, QGroupBox,
|
||||
QRadioButton, QDialogButtonBox)
|
||||
|
||||
|
||||
class JoinPagesDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Joindre deux pages")
|
||||
self.setMinimumWidth(300)
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
layout.addWidget(QLabel("Les pages seront jointes par paires (1+2, 3+4, ...)."))
|
||||
|
||||
grp = QGroupBox("Direction")
|
||||
gl = QVBoxLayout(grp)
|
||||
self.rb_horizontal = QRadioButton("Cote a cote (horizontal)")
|
||||
self.rb_horizontal.setChecked(True)
|
||||
self.rb_vertical = QRadioButton("Dessus/dessous (vertical)")
|
||||
gl.addWidget(self.rb_horizontal)
|
||||
gl.addWidget(self.rb_vertical)
|
||||
layout.addWidget(grp)
|
||||
|
||||
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 {
|
||||
"direction": "horizontal" if self.rb_horizontal.isChecked() else "vertical"
|
||||
}
|
||||
122
app/dialogs/manual_impose_dialog.py
Normal file
122
app/dialogs/manual_impose_dialog.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Dialogue Imposition manuelle."""
|
||||
|
||||
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QGroupBox, QComboBox, QSpinBox, QDoubleSpinBox,
|
||||
QDialogButtonBox, QPushButton, QListWidget,
|
||||
QCheckBox)
|
||||
from app.engine.pdf_utils import PAGE_SIZES, mm_to_pt
|
||||
|
||||
|
||||
class ManualImposeDialog(QDialog):
|
||||
def __init__(self, total_pages=1, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Imposition manuelle")
|
||||
self.setMinimumWidth(500)
|
||||
self._total_pages = total_pages
|
||||
self._placements = []
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Taille feuille
|
||||
grp_sheet = QGroupBox("Taille de la feuille cible")
|
||||
sl = QHBoxLayout(grp_sheet)
|
||||
sl.addWidget(QLabel("Format :"))
|
||||
self.combo_sheet = QComboBox()
|
||||
for name in PAGE_SIZES:
|
||||
self.combo_sheet.addItem(name)
|
||||
self.combo_sheet.setCurrentText("A3 Paysage")
|
||||
sl.addWidget(self.combo_sheet)
|
||||
layout.addWidget(grp_sheet)
|
||||
|
||||
# Placement
|
||||
grp_place = QGroupBox("Placer une page")
|
||||
pl = QHBoxLayout(grp_place)
|
||||
|
||||
pl.addWidget(QLabel("Page:"))
|
||||
self.spin_page = QSpinBox()
|
||||
self.spin_page.setRange(1, self._total_pages)
|
||||
pl.addWidget(self.spin_page)
|
||||
|
||||
pl.addWidget(QLabel("X:"))
|
||||
self.spin_x = QDoubleSpinBox()
|
||||
self.spin_x.setRange(0, 2000)
|
||||
self.spin_x.setSuffix(" mm")
|
||||
pl.addWidget(self.spin_x)
|
||||
|
||||
pl.addWidget(QLabel("Y:"))
|
||||
self.spin_y = QDoubleSpinBox()
|
||||
self.spin_y.setRange(0, 2000)
|
||||
self.spin_y.setSuffix(" mm")
|
||||
pl.addWidget(self.spin_y)
|
||||
|
||||
pl.addWidget(QLabel("Rot:"))
|
||||
self.combo_rot = QComboBox()
|
||||
self.combo_rot.addItems(["0", "90", "180", "270"])
|
||||
pl.addWidget(self.combo_rot)
|
||||
|
||||
pl.addWidget(QLabel("Ech:"))
|
||||
self.spin_scale = QDoubleSpinBox()
|
||||
self.spin_scale.setRange(0.1, 10.0)
|
||||
self.spin_scale.setValue(1.0)
|
||||
self.spin_scale.setSingleStep(0.1)
|
||||
pl.addWidget(self.spin_scale)
|
||||
|
||||
layout.addWidget(grp_place)
|
||||
|
||||
# Boutons
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_add = QPushButton("Ajouter")
|
||||
btn_add.clicked.connect(self._add_placement)
|
||||
btn_layout.addWidget(btn_add)
|
||||
btn_del = QPushButton("Supprimer")
|
||||
btn_del.clicked.connect(self._del_placement)
|
||||
btn_layout.addWidget(btn_del)
|
||||
btn_layout.addStretch()
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
# Liste
|
||||
self.list_placements = QListWidget()
|
||||
layout.addWidget(self.list_placements)
|
||||
|
||||
# Crop marks
|
||||
self.chk_crop = QCheckBox("Reperes de coupe")
|
||||
layout.addWidget(self.chk_crop)
|
||||
|
||||
# Boutons OK/Annuler
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
def _add_placement(self):
|
||||
p = {
|
||||
"page": self.spin_page.value(),
|
||||
"x": mm_to_pt(self.spin_x.value()),
|
||||
"y": mm_to_pt(self.spin_y.value()),
|
||||
"rotation": int(self.combo_rot.currentText()),
|
||||
"scale": self.spin_scale.value(),
|
||||
}
|
||||
self._placements.append(p)
|
||||
self.list_placements.addItem(
|
||||
f"Page {p['page']} - X={self.spin_x.value():.1f} Y={self.spin_y.value():.1f} mm "
|
||||
f"Rot={self.combo_rot.currentText()} Ech={self.spin_scale.value():.1f}"
|
||||
)
|
||||
|
||||
def _del_placement(self):
|
||||
row = self.list_placements.currentRow()
|
||||
if row >= 0:
|
||||
self.list_placements.takeItem(row)
|
||||
self._placements.pop(row)
|
||||
|
||||
def get_settings(self):
|
||||
sheet_text = self.combo_sheet.currentText()
|
||||
sheet_size = PAGE_SIZES[sheet_text]
|
||||
return {
|
||||
"sheet_size": sheet_size,
|
||||
"placements": self._placements,
|
||||
"crop_marks": self.chk_crop.isChecked(),
|
||||
}
|
||||
112
app/dialogs/masking_dialog.py
Normal file
112
app/dialogs/masking_dialog.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Dialogue Masquage (rectangles blancs)."""
|
||||
|
||||
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QGroupBox, QComboBox, QDoubleSpinBox,
|
||||
QDialogButtonBox, QPushButton, QListWidget,
|
||||
QListWidgetItem)
|
||||
from app.engine.pdf_utils import mm_to_pt
|
||||
|
||||
|
||||
class MaskingDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Masquage (ruban blanc)")
|
||||
self.setMinimumWidth(450)
|
||||
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)
|
||||
|
||||
rl.addWidget(QLabel("X:"))
|
||||
self.spin_x = QDoubleSpinBox()
|
||||
self.spin_x.setRange(0, 1000)
|
||||
self.spin_x.setSuffix(" mm")
|
||||
rl.addWidget(self.spin_x)
|
||||
|
||||
rl.addWidget(QLabel("Y:"))
|
||||
self.spin_y = QDoubleSpinBox()
|
||||
self.spin_y.setRange(0, 1000)
|
||||
self.spin_y.setSuffix(" mm")
|
||||
rl.addWidget(self.spin_y)
|
||||
|
||||
rl.addWidget(QLabel("L:"))
|
||||
self.spin_w = QDoubleSpinBox()
|
||||
self.spin_w.setRange(1, 1000)
|
||||
self.spin_w.setValue(50)
|
||||
self.spin_w.setSuffix(" mm")
|
||||
rl.addWidget(self.spin_w)
|
||||
|
||||
rl.addWidget(QLabel("H:"))
|
||||
self.spin_h = QDoubleSpinBox()
|
||||
self.spin_h.setRange(1, 1000)
|
||||
self.spin_h.setValue(10)
|
||||
self.spin_h.setSuffix(" mm")
|
||||
rl.addWidget(self.spin_h)
|
||||
|
||||
layout.addWidget(grp_rect)
|
||||
|
||||
# Boutons ajouter/supprimer
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_add = QPushButton("Ajouter")
|
||||
btn_add.clicked.connect(self._add_rect)
|
||||
btn_layout.addWidget(btn_add)
|
||||
btn_del = QPushButton("Supprimer")
|
||||
btn_del.clicked.connect(self._del_rect)
|
||||
btn_layout.addWidget(btn_del)
|
||||
btn_layout.addStretch()
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
# Liste des rectangles
|
||||
self.list_rects = QListWidget()
|
||||
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)
|
||||
layout.addWidget(grp_apply)
|
||||
|
||||
# Boutons
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
def _add_rect(self):
|
||||
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()),
|
||||
}
|
||||
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"
|
||||
)
|
||||
|
||||
def _del_rect(self):
|
||||
row = self.list_rects.currentRow()
|
||||
if row >= 0:
|
||||
self.list_rects.takeItem(row)
|
||||
self._rectangles.pop(row)
|
||||
|
||||
def get_settings(self):
|
||||
apply_map = {
|
||||
"Toutes les pages": "all",
|
||||
"Pages paires": "even",
|
||||
"Pages impaires": "odd"
|
||||
}
|
||||
return {
|
||||
"rectangles": self._rectangles,
|
||||
"apply_to": apply_map[self.combo_apply.currentText()],
|
||||
}
|
||||
131
app/dialogs/nup_dialog.py
Normal file
131
app/dialogs/nup_dialog.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Dialogue N pages par feuille."""
|
||||
|
||||
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QComboBox, QGroupBox, QSpinBox, QCheckBox,
|
||||
QDialogButtonBox, QDoubleSpinBox, QFileDialog)
|
||||
from app.engine.pdf_utils import PAGE_SIZES, mm_to_pt
|
||||
|
||||
|
||||
class NupDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("N pages par feuille")
|
||||
self.setMinimumWidth(450)
|
||||
self._bg_path = None
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Grille
|
||||
grp_grid = QGroupBox("Disposition")
|
||||
gl = QHBoxLayout(grp_grid)
|
||||
gl.addWidget(QLabel("Colonnes :"))
|
||||
self.spin_cols = QSpinBox()
|
||||
self.spin_cols.setRange(1, 10)
|
||||
self.spin_cols.setValue(2)
|
||||
gl.addWidget(self.spin_cols)
|
||||
gl.addWidget(QLabel("Lignes :"))
|
||||
self.spin_rows = QSpinBox()
|
||||
self.spin_rows.setRange(1, 10)
|
||||
self.spin_rows.setValue(2)
|
||||
gl.addWidget(self.spin_rows)
|
||||
layout.addWidget(grp_grid)
|
||||
|
||||
# Taille feuille
|
||||
grp_sheet = QGroupBox("Taille de la feuille")
|
||||
sl = QHBoxLayout(grp_sheet)
|
||||
sl.addWidget(QLabel("Format :"))
|
||||
self.combo_sheet = QComboBox()
|
||||
for name in PAGE_SIZES:
|
||||
self.combo_sheet.addItem(name)
|
||||
self.combo_sheet.setCurrentText("A4 Paysage")
|
||||
sl.addWidget(self.combo_sheet)
|
||||
layout.addWidget(grp_sheet)
|
||||
|
||||
# Marges
|
||||
grp_margins = QGroupBox("Marges (mm)")
|
||||
ml = QHBoxLayout(grp_margins)
|
||||
self.margins = {}
|
||||
for label in ["Haut", "Bas", "Gauche", "Droite"]:
|
||||
ml.addWidget(QLabel(f"{label}:"))
|
||||
spin = QDoubleSpinBox()
|
||||
spin.setRange(0, 100)
|
||||
spin.setValue(5)
|
||||
spin.setSuffix(" mm")
|
||||
ml.addWidget(spin)
|
||||
self.margins[label.lower()] = spin
|
||||
layout.addWidget(grp_margins)
|
||||
|
||||
# Gouttiere
|
||||
grp_gutter = QGroupBox("Gouttiere (mm)")
|
||||
gutl = QHBoxLayout(grp_gutter)
|
||||
gutl.addWidget(QLabel("Horizontale :"))
|
||||
self.spin_gutter_h = QDoubleSpinBox()
|
||||
self.spin_gutter_h.setRange(0, 50)
|
||||
self.spin_gutter_h.setValue(0)
|
||||
self.spin_gutter_h.setSuffix(" mm")
|
||||
gutl.addWidget(self.spin_gutter_h)
|
||||
gutl.addWidget(QLabel("Verticale :"))
|
||||
self.spin_gutter_v = QDoubleSpinBox()
|
||||
self.spin_gutter_v.setRange(0, 50)
|
||||
self.spin_gutter_v.setValue(0)
|
||||
self.spin_gutter_v.setSuffix(" mm")
|
||||
gutl.addWidget(self.spin_gutter_v)
|
||||
layout.addWidget(grp_gutter)
|
||||
|
||||
# Options
|
||||
grp_opts = QGroupBox("Options")
|
||||
ol = QVBoxLayout(grp_opts)
|
||||
self.chk_fit = QCheckBox("Ajuster les pages a la cellule")
|
||||
self.chk_fit.setChecked(True)
|
||||
ol.addWidget(self.chk_fit)
|
||||
self.chk_crop = QCheckBox("Reperes de coupe")
|
||||
ol.addWidget(self.chk_crop)
|
||||
|
||||
bg_layout = QHBoxLayout()
|
||||
self.chk_bg = QCheckBox("Fond PDF :")
|
||||
bg_layout.addWidget(self.chk_bg)
|
||||
self.lbl_bg = QLabel("Aucun")
|
||||
bg_layout.addWidget(self.lbl_bg)
|
||||
from PyQt6.QtWidgets import QPushButton
|
||||
btn_bg = QPushButton("Choisir...")
|
||||
btn_bg.clicked.connect(self._choose_bg)
|
||||
bg_layout.addWidget(btn_bg)
|
||||
bg_layout.addStretch()
|
||||
ol.addLayout(bg_layout)
|
||||
|
||||
layout.addWidget(grp_opts)
|
||||
|
||||
# Boutons
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
def _choose_bg(self):
|
||||
path, _ = QFileDialog.getOpenFileName(self, "Choisir un fond PDF", "", "PDF (*.pdf)")
|
||||
if path:
|
||||
self._bg_path = path
|
||||
self.lbl_bg.setText(path.split("/")[-1].split("\\")[-1])
|
||||
self.chk_bg.setChecked(True)
|
||||
|
||||
def get_settings(self):
|
||||
sheet_text = self.combo_sheet.currentText()
|
||||
sheet_size = PAGE_SIZES[sheet_text]
|
||||
return {
|
||||
"cols": self.spin_cols.value(),
|
||||
"rows": self.spin_rows.value(),
|
||||
"sheet_size": sheet_size,
|
||||
"margin_top": mm_to_pt(self.margins["haut"].value()),
|
||||
"margin_bottom": mm_to_pt(self.margins["bas"].value()),
|
||||
"margin_left": mm_to_pt(self.margins["gauche"].value()),
|
||||
"margin_right": mm_to_pt(self.margins["droite"].value()),
|
||||
"gutter_h": mm_to_pt(self.spin_gutter_h.value()),
|
||||
"gutter_v": mm_to_pt(self.spin_gutter_v.value()),
|
||||
"scale_mode": "fit" if self.chk_fit.isChecked() else "none",
|
||||
"crop_marks": self.chk_crop.isChecked(),
|
||||
"background_path": self._bg_path if self.chk_bg.isChecked() else None,
|
||||
}
|
||||
120
app/dialogs/page_numbers_dialog.py
Normal file
120
app/dialogs/page_numbers_dialog.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Dialogue Numerotation de pages."""
|
||||
|
||||
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QGroupBox, QComboBox, QSpinBox, QLineEdit,
|
||||
QDialogButtonBox, QDoubleSpinBox)
|
||||
from reportlab.lib.units import mm
|
||||
|
||||
|
||||
class PageNumbersDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Numerotation de pages")
|
||||
self.setMinimumWidth(400)
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Position
|
||||
grp_pos = QGroupBox("Position")
|
||||
pl = QHBoxLayout(grp_pos)
|
||||
pl.addWidget(QLabel("Emplacement :"))
|
||||
self.combo_pos = QComboBox()
|
||||
positions = [
|
||||
"haut-gauche", "haut-centre", "haut-droite",
|
||||
"milieu-gauche", "milieu-centre", "milieu-droite",
|
||||
"bas-gauche", "bas-centre", "bas-droite"
|
||||
]
|
||||
self.combo_pos.addItems(positions)
|
||||
self.combo_pos.setCurrentText("bas-centre")
|
||||
pl.addWidget(self.combo_pos)
|
||||
layout.addWidget(grp_pos)
|
||||
|
||||
# Police
|
||||
grp_font = QGroupBox("Police")
|
||||
fl = QHBoxLayout(grp_font)
|
||||
fl.addWidget(QLabel("Police :"))
|
||||
self.combo_font = QComboBox()
|
||||
self.combo_font.addItems(["Helvetica", "Times-Roman", "Courier"])
|
||||
fl.addWidget(self.combo_font)
|
||||
fl.addWidget(QLabel("Taille :"))
|
||||
self.spin_size = QSpinBox()
|
||||
self.spin_size.setRange(4, 72)
|
||||
self.spin_size.setValue(10)
|
||||
fl.addWidget(self.spin_size)
|
||||
layout.addWidget(grp_font)
|
||||
|
||||
# Texte
|
||||
grp_text = QGroupBox("Texte")
|
||||
tl = QHBoxLayout(grp_text)
|
||||
tl.addWidget(QLabel("Prefixe :"))
|
||||
self.edit_prefix = QLineEdit()
|
||||
self.edit_prefix.setPlaceholderText("ex: Page ")
|
||||
tl.addWidget(self.edit_prefix)
|
||||
tl.addWidget(QLabel("Suffixe :"))
|
||||
self.edit_suffix = QLineEdit()
|
||||
self.edit_suffix.setPlaceholderText("ex: / 24")
|
||||
tl.addWidget(self.edit_suffix)
|
||||
layout.addWidget(grp_text)
|
||||
|
||||
# Numero de depart
|
||||
start_layout = QHBoxLayout()
|
||||
start_layout.addWidget(QLabel("Commencer a :"))
|
||||
self.spin_start = QSpinBox()
|
||||
self.spin_start.setRange(0, 9999)
|
||||
self.spin_start.setValue(1)
|
||||
start_layout.addWidget(self.spin_start)
|
||||
start_layout.addStretch()
|
||||
layout.addLayout(start_layout)
|
||||
|
||||
# Marges
|
||||
margin_layout = QHBoxLayout()
|
||||
margin_layout.addWidget(QLabel("Marge X :"))
|
||||
self.spin_mx = QDoubleSpinBox()
|
||||
self.spin_mx.setRange(0, 100)
|
||||
self.spin_mx.setValue(10)
|
||||
self.spin_mx.setSuffix(" mm")
|
||||
margin_layout.addWidget(self.spin_mx)
|
||||
margin_layout.addWidget(QLabel("Marge Y :"))
|
||||
self.spin_my = QDoubleSpinBox()
|
||||
self.spin_my.setRange(0, 100)
|
||||
self.spin_my.setValue(10)
|
||||
self.spin_my.setSuffix(" mm")
|
||||
margin_layout.addWidget(self.spin_my)
|
||||
layout.addLayout(margin_layout)
|
||||
|
||||
# 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)
|
||||
layout.addWidget(grp_apply)
|
||||
|
||||
# Boutons
|
||||
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):
|
||||
apply_map = {
|
||||
"Toutes les pages": "all",
|
||||
"Pages paires": "even",
|
||||
"Pages impaires": "odd"
|
||||
}
|
||||
return {
|
||||
"position": self.combo_pos.currentText(),
|
||||
"font_name": self.combo_font.currentText(),
|
||||
"font_size": self.spin_size.value(),
|
||||
"color": (0, 0, 0),
|
||||
"prefix": self.edit_prefix.text(),
|
||||
"suffix": self.edit_suffix.text(),
|
||||
"start_number": self.spin_start.value(),
|
||||
"margin_x": self.spin_mx.value() * mm,
|
||||
"margin_y": self.spin_my.value() * mm,
|
||||
"apply_to": apply_map[self.combo_apply.currentText()],
|
||||
}
|
||||
95
app/dialogs/step_repeat_dialog.py
Normal file
95
app/dialogs/step_repeat_dialog.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Dialogue Repetition (Step & Repeat)."""
|
||||
|
||||
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QComboBox, QGroupBox, QSpinBox, QCheckBox,
|
||||
QDialogButtonBox, QDoubleSpinBox)
|
||||
from app.engine.pdf_utils import PAGE_SIZES, mm_to_pt
|
||||
|
||||
|
||||
class StepRepeatDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Repetition (Step & Repeat)")
|
||||
self.setMinimumWidth(400)
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Taille feuille
|
||||
grp_sheet = QGroupBox("Taille de la feuille")
|
||||
sl = QHBoxLayout(grp_sheet)
|
||||
sl.addWidget(QLabel("Format :"))
|
||||
self.combo_sheet = QComboBox()
|
||||
for name in PAGE_SIZES:
|
||||
self.combo_sheet.addItem(name)
|
||||
self.combo_sheet.setCurrentText("A3 Paysage")
|
||||
sl.addWidget(self.combo_sheet)
|
||||
layout.addWidget(grp_sheet)
|
||||
|
||||
# Nombre de copies
|
||||
grp_copies = QGroupBox("Nombre de copies")
|
||||
cl = QVBoxLayout(grp_copies)
|
||||
self.chk_auto = QCheckBox("Automatique (remplir la feuille)")
|
||||
self.chk_auto.setChecked(True)
|
||||
cl.addWidget(self.chk_auto)
|
||||
|
||||
manual = QHBoxLayout()
|
||||
manual.addWidget(QLabel("Horizontal :"))
|
||||
self.spin_h = QSpinBox()
|
||||
self.spin_h.setRange(1, 20)
|
||||
self.spin_h.setValue(2)
|
||||
self.spin_h.setEnabled(False)
|
||||
manual.addWidget(self.spin_h)
|
||||
manual.addWidget(QLabel("Vertical :"))
|
||||
self.spin_v = QSpinBox()
|
||||
self.spin_v.setRange(1, 20)
|
||||
self.spin_v.setValue(2)
|
||||
self.spin_v.setEnabled(False)
|
||||
manual.addWidget(self.spin_v)
|
||||
cl.addLayout(manual)
|
||||
layout.addWidget(grp_copies)
|
||||
|
||||
self.chk_auto.toggled.connect(lambda checked: self.spin_h.setEnabled(not checked))
|
||||
self.chk_auto.toggled.connect(lambda checked: self.spin_v.setEnabled(not checked))
|
||||
|
||||
# Espacement
|
||||
grp_spacing = QGroupBox("Espacement (mm)")
|
||||
spl = QHBoxLayout(grp_spacing)
|
||||
spl.addWidget(QLabel("Horizontal :"))
|
||||
self.spin_sp_h = QDoubleSpinBox()
|
||||
self.spin_sp_h.setRange(0, 50)
|
||||
self.spin_sp_h.setValue(0)
|
||||
self.spin_sp_h.setSuffix(" mm")
|
||||
spl.addWidget(self.spin_sp_h)
|
||||
spl.addWidget(QLabel("Vertical :"))
|
||||
self.spin_sp_v = QDoubleSpinBox()
|
||||
self.spin_sp_v.setRange(0, 50)
|
||||
self.spin_sp_v.setValue(0)
|
||||
self.spin_sp_v.setSuffix(" mm")
|
||||
spl.addWidget(self.spin_sp_v)
|
||||
layout.addWidget(grp_spacing)
|
||||
|
||||
# Options
|
||||
self.chk_crop = QCheckBox("Reperes de coupe")
|
||||
layout.addWidget(self.chk_crop)
|
||||
|
||||
# Boutons
|
||||
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):
|
||||
sheet_text = self.combo_sheet.currentText()
|
||||
sheet_size = PAGE_SIZES[sheet_text]
|
||||
return {
|
||||
"sheet_size": sheet_size,
|
||||
"copies_h": None if self.chk_auto.isChecked() else self.spin_h.value(),
|
||||
"copies_v": None if self.chk_auto.isChecked() else self.spin_v.value(),
|
||||
"spacing_h": mm_to_pt(self.spin_sp_h.value()),
|
||||
"spacing_v": mm_to_pt(self.spin_sp_v.value()),
|
||||
"crop_marks": self.chk_crop.isChecked(),
|
||||
}
|
||||
0
app/engine/__init__.py
Normal file
0
app/engine/__init__.py
Normal file
36
app/engine/bleeds.py
Normal file
36
app/engine/bleeds.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Moteur Definition des fonds perdus (bleeds)."""
|
||||
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
from .pdf_utils import get_page_size, mm_to_pt
|
||||
|
||||
|
||||
def define_bleeds(input_path, output_path, bleed_top=0, bleed_bottom=0,
|
||||
bleed_left=0, bleed_right=0):
|
||||
"""Definir les zones de fond perdu en etendant le TrimBox/BleedBox.
|
||||
|
||||
Les valeurs sont en millimetres.
|
||||
"""
|
||||
bt = mm_to_pt(bleed_top)
|
||||
bb = mm_to_pt(bleed_bottom)
|
||||
bl = mm_to_pt(bleed_left)
|
||||
br = mm_to_pt(bleed_right)
|
||||
|
||||
reader = PdfReader(input_path)
|
||||
writer = PdfWriter()
|
||||
|
||||
for page in reader.pages:
|
||||
w, h = get_page_size(page)
|
||||
|
||||
# Le TrimBox definit la taille finale apres coupe
|
||||
page.trimbox.lower_left = (bl, bb)
|
||||
page.trimbox.upper_right = (w - br, h - bt)
|
||||
|
||||
# Le BleedBox inclut le fond perdu
|
||||
page.bleedbox.lower_left = (0, 0)
|
||||
page.bleedbox.upper_right = (w, h)
|
||||
|
||||
writer.add_page(page)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
return output_path
|
||||
196
app/engine/booklet.py
Normal file
196
app/engine/booklet.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""Moteur de creation de livrets."""
|
||||
|
||||
from pypdf import PdfReader, PdfWriter, PageObject, Transformation
|
||||
from .pdf_utils import get_page_size, create_blank_page, pad_to_multiple
|
||||
import io
|
||||
import math
|
||||
|
||||
|
||||
def create_booklet(input_path, output_path, binding="saddle_stitch", sheet_size=None,
|
||||
signature_size=32, alignment="center"):
|
||||
"""Creer un livret a partir d'un PDF.
|
||||
|
||||
binding: "continuous", "saddle_stitch", "perfect_bound", "cut_stacks"
|
||||
sheet_size: (largeur, hauteur) en points, ou None pour auto (2x page)
|
||||
signature_size: nombre de pages par cahier (perfect_bound uniquement)
|
||||
alignment: "bottom_left", "center", "pull_center"
|
||||
"""
|
||||
reader = PdfReader(input_path)
|
||||
|
||||
if binding == "continuous":
|
||||
return _booklet_continuous(reader, output_path, sheet_size, alignment)
|
||||
elif binding == "saddle_stitch":
|
||||
return _booklet_saddle_stitch(reader, output_path, sheet_size, alignment)
|
||||
elif binding == "perfect_bound":
|
||||
return _booklet_perfect_bound(reader, output_path, sheet_size, signature_size, alignment)
|
||||
elif binding == "cut_stacks":
|
||||
return _booklet_cut_stacks(reader, output_path, sheet_size, alignment)
|
||||
|
||||
|
||||
def _get_sheet_size(reader, sheet_size):
|
||||
"""Determiner la taille de la feuille."""
|
||||
page_w, page_h = get_page_size(reader.pages[0])
|
||||
if sheet_size:
|
||||
return sheet_size
|
||||
# Auto: feuille = 2x largeur de page
|
||||
return (page_w * 2, page_h)
|
||||
|
||||
|
||||
def _place_two_pages(left_page, right_page, sheet_w, sheet_h, page_w, page_h, alignment):
|
||||
"""Placer deux pages cote a cote sur une feuille."""
|
||||
sheet = PageObject.create_blank_page(width=sheet_w, height=sheet_h)
|
||||
|
||||
half_w = sheet_w / 2
|
||||
|
||||
if alignment == "center":
|
||||
left_x = (half_w - page_w) / 2
|
||||
right_x = half_w + (half_w - page_w) / 2
|
||||
y = (sheet_h - page_h) / 2
|
||||
elif alignment == "pull_center":
|
||||
left_x = half_w - page_w
|
||||
right_x = half_w
|
||||
y = (sheet_h - page_h) / 2
|
||||
else: # bottom_left
|
||||
left_x = 0
|
||||
right_x = half_w
|
||||
y = 0
|
||||
|
||||
if left_page:
|
||||
left_copy = PageObject.create_blank_page(width=sheet_w, height=sheet_h)
|
||||
left_copy.merge_page(left_page)
|
||||
left_copy.add_transformation(Transformation().translate(left_x, y))
|
||||
sheet.merge_page(left_copy)
|
||||
|
||||
if right_page:
|
||||
right_copy = PageObject.create_blank_page(width=sheet_w, height=sheet_h)
|
||||
right_copy.merge_page(right_page)
|
||||
right_copy.add_transformation(Transformation().translate(right_x, y))
|
||||
sheet.merge_page(right_copy)
|
||||
|
||||
return sheet
|
||||
|
||||
|
||||
def _booklet_continuous(reader, output_path, sheet_size, alignment):
|
||||
"""Mode continu: pages 1+2, 3+4, etc."""
|
||||
reader = pad_to_multiple(reader, 2)
|
||||
pages = reader.pages
|
||||
page_w, page_h = get_page_size(pages[0])
|
||||
sheet_w, sheet_h = _get_sheet_size(reader, sheet_size)
|
||||
|
||||
writer = PdfWriter()
|
||||
for i in range(0, len(pages), 2):
|
||||
left = pages[i]
|
||||
right = pages[i + 1] if i + 1 < len(pages) else None
|
||||
sheet = _place_two_pages(left, right, sheet_w, sheet_h, page_w, page_h, alignment)
|
||||
writer.add_page(sheet)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
return output_path
|
||||
|
||||
|
||||
def _saddle_stitch_order(num_pages):
|
||||
"""Calculer l'ordre des pages pour piqure a cheval.
|
||||
|
||||
Pour N pages (multiple de 4):
|
||||
Feuille 1 recto: page N, page 1
|
||||
Feuille 1 verso: page 2, page N-1
|
||||
Feuille 2 recto: page N-2, page 3
|
||||
etc.
|
||||
"""
|
||||
pairs = []
|
||||
n = num_pages
|
||||
for i in range(n // 4):
|
||||
# Recto
|
||||
pairs.append((n - 1 - 2 * i, 2 * i))
|
||||
# Verso
|
||||
pairs.append((2 * i + 1, n - 2 - 2 * i))
|
||||
return pairs
|
||||
|
||||
|
||||
def _booklet_saddle_stitch(reader, output_path, sheet_size, alignment):
|
||||
"""Piqure a cheval."""
|
||||
reader = pad_to_multiple(reader, 4)
|
||||
pages = reader.pages
|
||||
num_pages = len(pages)
|
||||
page_w, page_h = get_page_size(pages[0])
|
||||
sheet_w, sheet_h = _get_sheet_size(reader, sheet_size)
|
||||
|
||||
pairs = _saddle_stitch_order(num_pages)
|
||||
writer = PdfWriter()
|
||||
|
||||
for left_idx, right_idx in pairs:
|
||||
left = pages[left_idx] if left_idx < num_pages else None
|
||||
right = pages[right_idx] if right_idx < num_pages else None
|
||||
sheet = _place_two_pages(left, right, sheet_w, sheet_h, page_w, page_h, alignment)
|
||||
writer.add_page(sheet)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
return output_path
|
||||
|
||||
|
||||
def _booklet_perfect_bound(reader, output_path, sheet_size, signature_size, alignment):
|
||||
"""Dos carre colle: plusieurs cahiers de signature_size pages."""
|
||||
reader = pad_to_multiple(reader, 4)
|
||||
pages = reader.pages
|
||||
num_pages = len(pages)
|
||||
page_w, page_h = get_page_size(pages[0])
|
||||
sheet_w, sheet_h = _get_sheet_size(reader, sheet_size)
|
||||
|
||||
writer = PdfWriter()
|
||||
|
||||
# Decouper en signatures
|
||||
for sig_start in range(0, num_pages, signature_size):
|
||||
sig_end = min(sig_start + signature_size, num_pages)
|
||||
sig_pages = list(range(sig_start, sig_end))
|
||||
|
||||
# Padding de la signature
|
||||
while len(sig_pages) % 4 != 0:
|
||||
sig_pages.append(None)
|
||||
|
||||
n = len(sig_pages)
|
||||
for i in range(n // 4):
|
||||
# Recto
|
||||
left_idx = sig_pages[n - 1 - 2 * i]
|
||||
right_idx = sig_pages[2 * i]
|
||||
left = pages[left_idx] if left_idx is not None and left_idx < num_pages else None
|
||||
right = pages[right_idx] if right_idx is not None and right_idx < num_pages else None
|
||||
sheet = _place_two_pages(left, right, sheet_w, sheet_h, page_w, page_h, alignment)
|
||||
writer.add_page(sheet)
|
||||
|
||||
# Verso
|
||||
left_idx = sig_pages[2 * i + 1]
|
||||
right_idx = sig_pages[n - 2 - 2 * i]
|
||||
left = pages[left_idx] if left_idx is not None and left_idx < num_pages else None
|
||||
right = pages[right_idx] if right_idx is not None and right_idx < num_pages else None
|
||||
sheet = _place_two_pages(left, right, sheet_w, sheet_h, page_w, page_h, alignment)
|
||||
writer.add_page(sheet)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
return output_path
|
||||
|
||||
|
||||
def _booklet_cut_stacks(reader, output_path, sheet_size, alignment):
|
||||
"""Cut stacks: imprimer, couper en deux, empiler."""
|
||||
reader = pad_to_multiple(reader, 4)
|
||||
pages = reader.pages
|
||||
num_pages = len(pages)
|
||||
page_w, page_h = get_page_size(pages[0])
|
||||
sheet_w, sheet_h = _get_sheet_size(reader, sheet_size)
|
||||
|
||||
writer = PdfWriter()
|
||||
half = num_pages // 2
|
||||
|
||||
for i in range(half):
|
||||
top_idx = i
|
||||
bottom_idx = half + i
|
||||
left = pages[top_idx] if top_idx < num_pages else None
|
||||
right = pages[bottom_idx] if bottom_idx < num_pages else None
|
||||
sheet = _place_two_pages(left, right, sheet_w, sheet_h, page_w, page_h, alignment)
|
||||
writer.add_page(sheet)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
return output_path
|
||||
58
app/engine/insert_blank.py
Normal file
58
app/engine/insert_blank.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Moteur Insertion de pages blanches."""
|
||||
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
from .pdf_utils import get_page_size
|
||||
|
||||
|
||||
def insert_blank_pages(input_path, output_path, mode="after", position=None,
|
||||
every_n=None, target_multiple=None, page_size=None):
|
||||
"""Inserer des pages blanches.
|
||||
|
||||
mode: "before", "after", "every_n", "pad_to_multiple"
|
||||
position: numero de page (1-based) pour before/after
|
||||
every_n: inserer apres chaque N pages
|
||||
target_multiple: arrondir au multiple de N
|
||||
page_size: (w, h) en points ou None pour meme taille que page adjacente
|
||||
"""
|
||||
reader = PdfReader(input_path)
|
||||
writer = PdfWriter()
|
||||
|
||||
if mode == "pad_to_multiple" and target_multiple:
|
||||
for page in reader.pages:
|
||||
writer.add_page(page)
|
||||
num = len(reader.pages)
|
||||
remainder = num % target_multiple
|
||||
if remainder != 0:
|
||||
to_add = target_multiple - remainder
|
||||
last_w, last_h = get_page_size(reader.pages[-1])
|
||||
w, h = page_size if page_size else (last_w, last_h)
|
||||
for _ in range(to_add):
|
||||
writer.add_blank_page(w, h)
|
||||
|
||||
elif mode == "every_n" and every_n:
|
||||
for i, page in enumerate(reader.pages):
|
||||
writer.add_page(page)
|
||||
if (i + 1) % every_n == 0 and i + 1 < len(reader.pages):
|
||||
w, h = get_page_size(page)
|
||||
bw, bh = page_size if page_size else (w, h)
|
||||
writer.add_blank_page(bw, bh)
|
||||
|
||||
elif mode in ("before", "after") and position:
|
||||
idx = position - 1 # 0-based
|
||||
for i, page in enumerate(reader.pages):
|
||||
if mode == "before" and i == idx:
|
||||
w, h = get_page_size(page)
|
||||
bw, bh = page_size if page_size else (w, h)
|
||||
writer.add_blank_page(bw, bh)
|
||||
writer.add_page(page)
|
||||
if mode == "after" and i == idx:
|
||||
w, h = get_page_size(page)
|
||||
bw, bh = page_size if page_size else (w, h)
|
||||
writer.add_blank_page(bw, bh)
|
||||
else:
|
||||
for page in reader.pages:
|
||||
writer.add_page(page)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
return output_path
|
||||
60
app/engine/join_pages.py
Normal file
60
app/engine/join_pages.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Moteur Joindre deux pages."""
|
||||
|
||||
from pypdf import PdfReader, PdfWriter, PageObject, Transformation
|
||||
from .pdf_utils import get_page_size
|
||||
|
||||
|
||||
def join_pages(input_path, output_path, direction="horizontal"):
|
||||
"""Joindre les pages par paires.
|
||||
|
||||
direction: "horizontal" (cote a cote) ou "vertical" (dessus/dessous)
|
||||
"""
|
||||
reader = PdfReader(input_path)
|
||||
pages = reader.pages
|
||||
writer = PdfWriter()
|
||||
|
||||
for i in range(0, len(pages), 2):
|
||||
page1 = pages[i]
|
||||
page2 = pages[i + 1] if i + 1 < len(pages) else None
|
||||
|
||||
w1, h1 = get_page_size(page1)
|
||||
|
||||
if page2:
|
||||
w2, h2 = get_page_size(page2)
|
||||
else:
|
||||
w2, h2 = w1, h1
|
||||
|
||||
if direction == "horizontal":
|
||||
new_w = w1 + w2
|
||||
new_h = max(h1, h2)
|
||||
sheet = PageObject.create_blank_page(width=new_w, height=new_h)
|
||||
|
||||
overlay1 = PageObject.create_blank_page(width=new_w, height=new_h)
|
||||
overlay1.merge_page(page1)
|
||||
sheet.merge_page(overlay1)
|
||||
|
||||
if page2:
|
||||
overlay2 = PageObject.create_blank_page(width=new_w, height=new_h)
|
||||
overlay2.merge_page(page2)
|
||||
overlay2.add_transformation(Transformation().translate(w1, 0))
|
||||
sheet.merge_page(overlay2)
|
||||
else: # vertical
|
||||
new_w = max(w1, w2)
|
||||
new_h = h1 + h2
|
||||
sheet = PageObject.create_blank_page(width=new_w, height=new_h)
|
||||
|
||||
overlay1 = PageObject.create_blank_page(width=new_w, height=new_h)
|
||||
overlay1.merge_page(page1)
|
||||
overlay1.add_transformation(Transformation().translate(0, h2))
|
||||
sheet.merge_page(overlay1)
|
||||
|
||||
if page2:
|
||||
overlay2 = PageObject.create_blank_page(width=new_w, height=new_h)
|
||||
overlay2.merge_page(page2)
|
||||
sheet.merge_page(overlay2)
|
||||
|
||||
writer.add_page(sheet)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
return output_path
|
||||
68
app/engine/manual_impose.py
Normal file
68
app/engine/manual_impose.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Moteur Imposition manuelle."""
|
||||
|
||||
from pypdf import PdfReader, PdfWriter, PageObject, Transformation
|
||||
from .pdf_utils import get_page_size, create_crop_marks_page
|
||||
|
||||
|
||||
def manual_impose(input_path, output_path, sheet_size, placements, crop_marks=False):
|
||||
"""Imposition manuelle : placer des pages a des positions precises.
|
||||
|
||||
sheet_size: (largeur, hauteur) en points
|
||||
placements: liste de dicts:
|
||||
{"page": int (1-based), "x": float, "y": float, "scale": float, "rotation": int}
|
||||
"""
|
||||
reader = PdfReader(input_path)
|
||||
pages = reader.pages
|
||||
sheet_w, sheet_h = sheet_size
|
||||
|
||||
writer = PdfWriter()
|
||||
sheet = PageObject.create_blank_page(width=sheet_w, height=sheet_h)
|
||||
|
||||
mark_positions = []
|
||||
|
||||
for p in placements:
|
||||
page_idx = p["page"] - 1
|
||||
if page_idx < 0 or page_idx >= len(pages):
|
||||
continue
|
||||
|
||||
page = pages[page_idx]
|
||||
page_w, page_h = get_page_size(page)
|
||||
x = p.get("x", 0)
|
||||
y = p.get("y", 0)
|
||||
scale = p.get("scale", 1.0)
|
||||
rotation = p.get("rotation", 0)
|
||||
|
||||
overlay = PageObject.create_blank_page(width=sheet_w, height=sheet_h)
|
||||
overlay.merge_page(page)
|
||||
|
||||
transform = Transformation()
|
||||
if rotation:
|
||||
# Rotation autour du centre de la page
|
||||
cx = page_w / 2
|
||||
cy = page_h / 2
|
||||
transform = transform.translate(-cx, -cy)
|
||||
transform = transform.rotate(rotation)
|
||||
transform = transform.translate(cx, cy)
|
||||
transform = transform.scale(scale, scale).translate(x, y)
|
||||
overlay.add_transformation(transform)
|
||||
sheet.merge_page(overlay)
|
||||
|
||||
if crop_marks:
|
||||
scaled_w = page_w * scale
|
||||
scaled_h = page_h * scale
|
||||
mark_positions.extend([
|
||||
(x, y),
|
||||
(x + scaled_w, y),
|
||||
(x, y + scaled_h),
|
||||
(x + scaled_w, y + scaled_h),
|
||||
])
|
||||
|
||||
if crop_marks and mark_positions:
|
||||
marks = create_crop_marks_page(sheet_w, sheet_h, mark_positions)
|
||||
sheet.merge_page(marks)
|
||||
|
||||
writer.add_page(sheet)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
return output_path
|
||||
53
app/engine/masking.py
Normal file
53
app/engine/masking.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Moteur Masquage (Masking Tape) — rectangles blancs."""
|
||||
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
from reportlab.pdfgen import canvas
|
||||
from .pdf_utils import get_page_size
|
||||
import io
|
||||
|
||||
|
||||
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)
|
||||
"""
|
||||
reader = PdfReader(input_path)
|
||||
writer = PdfWriter()
|
||||
|
||||
for i, page in enumerate(reader.pages):
|
||||
page_num = i + 1
|
||||
|
||||
should_apply = False
|
||||
if apply_to == "all":
|
||||
should_apply = True
|
||||
elif apply_to == "even":
|
||||
should_apply = page_num % 2 == 0
|
||||
elif apply_to == "odd":
|
||||
should_apply = page_num % 2 != 0
|
||||
elif isinstance(apply_to, int):
|
||||
should_apply = page_num == apply_to
|
||||
|
||||
if should_apply and rectangles:
|
||||
w, h = get_page_size(page)
|
||||
packet = io.BytesIO()
|
||||
c = canvas.Canvas(packet, pagesize=(w, h))
|
||||
c.setFillColorRGB(1, 1, 1)
|
||||
c.setStrokeColorRGB(1, 1, 1)
|
||||
|
||||
for rect in rectangles:
|
||||
c.rect(rect["x"], rect["y"], rect["width"], rect["height"],
|
||||
fill=1, stroke=0)
|
||||
|
||||
c.showPage()
|
||||
c.save()
|
||||
packet.seek(0)
|
||||
|
||||
overlay_reader = PdfReader(packet)
|
||||
page.merge_page(overlay_reader.pages[0])
|
||||
|
||||
writer.add_page(page)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
return output_path
|
||||
102
app/engine/nup.py
Normal file
102
app/engine/nup.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Moteur N-Up : placer plusieurs pages par feuille."""
|
||||
|
||||
from pypdf import PdfReader, PdfWriter, PageObject, Transformation
|
||||
from .pdf_utils import get_page_size, create_blank_page, mm_to_pt, smart_crop_marks
|
||||
import io
|
||||
|
||||
|
||||
def nup_pages(input_path, output_path, cols=2, rows=2, sheet_size=(841.89, 595.28),
|
||||
margin_top=0, margin_bottom=0, margin_left=0, margin_right=0,
|
||||
gutter_h=0, gutter_v=0, scale_mode="fit", crop_marks=False,
|
||||
background_path=None):
|
||||
"""Placer plusieurs pages sur chaque feuille.
|
||||
|
||||
cols, rows: grille
|
||||
sheet_size: (largeur, hauteur) en points
|
||||
margins: en points
|
||||
gutter_h, gutter_v: espacement horizontal/vertical entre pages
|
||||
scale_mode: "fit" (ajuster) ou "none" (taille reelle)
|
||||
crop_marks: ajouter des reperes de coupe
|
||||
background_path: chemin vers un PDF de fond
|
||||
"""
|
||||
reader = PdfReader(input_path)
|
||||
pages = reader.pages
|
||||
sheet_w, sheet_h = sheet_size
|
||||
|
||||
# Zone utile
|
||||
usable_w = sheet_w - margin_left - margin_right - gutter_h * (cols - 1)
|
||||
usable_h = sheet_h - margin_top - margin_bottom - gutter_v * (rows - 1)
|
||||
|
||||
cell_w = usable_w / cols
|
||||
cell_h = usable_h / rows
|
||||
|
||||
# Fond
|
||||
bg_page = None
|
||||
if background_path:
|
||||
bg_reader = PdfReader(background_path)
|
||||
if bg_reader.pages:
|
||||
bg_page = bg_reader.pages[0]
|
||||
|
||||
writer = PdfWriter()
|
||||
pages_per_sheet = cols * rows
|
||||
|
||||
for sheet_idx in range(0, len(pages), pages_per_sheet):
|
||||
sheet = PageObject.create_blank_page(width=sheet_w, height=sheet_h)
|
||||
|
||||
if bg_page:
|
||||
sheet.merge_page(bg_page)
|
||||
|
||||
grid_positions = []
|
||||
|
||||
for slot in range(pages_per_sheet):
|
||||
page_idx = sheet_idx + slot
|
||||
if page_idx >= len(pages):
|
||||
break
|
||||
|
||||
page = pages[page_idx]
|
||||
page_w, page_h = get_page_size(page)
|
||||
|
||||
col = slot % cols
|
||||
row = slot // cols
|
||||
grid_positions.append((col, row))
|
||||
|
||||
# Position de la cellule
|
||||
x = margin_left + col * (cell_w + gutter_h)
|
||||
y = sheet_h - margin_top - (row + 1) * cell_h - row * gutter_v
|
||||
|
||||
# Calcul du scale
|
||||
if scale_mode == "fit":
|
||||
scale_x = cell_w / page_w
|
||||
scale_y = cell_h / page_h
|
||||
scale = min(scale_x, scale_y)
|
||||
else:
|
||||
scale = 1.0
|
||||
|
||||
# Centrage dans la cellule
|
||||
scaled_w = page_w * scale
|
||||
scaled_h = page_h * scale
|
||||
offset_x = x + (cell_w - scaled_w) / 2
|
||||
offset_y = y + (cell_h - scaled_h) / 2
|
||||
|
||||
# Placer la page
|
||||
overlay = PageObject.create_blank_page(width=sheet_w, height=sheet_h)
|
||||
overlay.merge_page(page)
|
||||
overlay.add_transformation(
|
||||
Transformation().scale(scale, scale).translate(offset_x, offset_y)
|
||||
)
|
||||
sheet.merge_page(overlay)
|
||||
|
||||
# Reperes de coupe
|
||||
if crop_marks and grid_positions:
|
||||
marks = smart_crop_marks(
|
||||
sheet_w, sheet_h, grid_positions,
|
||||
cell_w + gutter_h, cell_h + gutter_v,
|
||||
margin_left, margin_top
|
||||
)
|
||||
sheet.merge_page(marks)
|
||||
|
||||
writer.add_page(sheet)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
return output_path
|
||||
89
app/engine/page_numbers.py
Normal file
89
app/engine/page_numbers.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Moteur Numerotation de pages."""
|
||||
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.units import mm
|
||||
from .pdf_utils import get_page_size
|
||||
import io
|
||||
|
||||
|
||||
POSITIONS = {
|
||||
"haut-gauche": ("left", "top"),
|
||||
"haut-centre": ("center", "top"),
|
||||
"haut-droite": ("right", "top"),
|
||||
"milieu-gauche": ("left", "middle"),
|
||||
"milieu-centre": ("center", "middle"),
|
||||
"milieu-droite": ("right", "middle"),
|
||||
"bas-gauche": ("left", "bottom"),
|
||||
"bas-centre": ("center", "bottom"),
|
||||
"bas-droite": ("right", "bottom"),
|
||||
}
|
||||
|
||||
|
||||
def add_page_numbers(input_path, output_path, position="bas-centre",
|
||||
font_name="Helvetica", font_size=10, color=(0, 0, 0),
|
||||
prefix="", suffix="", start_number=1,
|
||||
margin_x=10*mm, margin_y=10*mm,
|
||||
apply_to="all"):
|
||||
"""Ajouter des numeros de page.
|
||||
|
||||
position: cle de POSITIONS
|
||||
apply_to: "all", "even", "odd"
|
||||
"""
|
||||
reader = PdfReader(input_path)
|
||||
writer = PdfWriter()
|
||||
|
||||
h_align, v_align = POSITIONS.get(position, ("center", "bottom"))
|
||||
total_pages = len(reader.pages)
|
||||
|
||||
for i, page in enumerate(reader.pages):
|
||||
page_num = start_number + i
|
||||
|
||||
# Filtre pair/impair
|
||||
if apply_to == "even" and page_num % 2 != 0:
|
||||
writer.add_page(page)
|
||||
continue
|
||||
if apply_to == "odd" and page_num % 2 == 0:
|
||||
writer.add_page(page)
|
||||
continue
|
||||
|
||||
w, h = get_page_size(page)
|
||||
|
||||
# Generer le texte
|
||||
text = f"{prefix}{page_num}{suffix}"
|
||||
|
||||
# Creer overlay avec le numero
|
||||
packet = io.BytesIO()
|
||||
c = canvas.Canvas(packet, pagesize=(w, h))
|
||||
c.setFont(font_name, font_size)
|
||||
c.setFillColorRGB(*color)
|
||||
|
||||
# Calculer la position
|
||||
text_width = c.stringWidth(text, font_name, font_size)
|
||||
|
||||
if h_align == "left":
|
||||
x = margin_x
|
||||
elif h_align == "right":
|
||||
x = w - margin_x - text_width
|
||||
else:
|
||||
x = (w - text_width) / 2
|
||||
|
||||
if v_align == "top":
|
||||
y = h - margin_y - font_size
|
||||
elif v_align == "middle":
|
||||
y = (h - font_size) / 2
|
||||
else:
|
||||
y = margin_y
|
||||
|
||||
c.drawString(x, y, text)
|
||||
c.showPage()
|
||||
c.save()
|
||||
|
||||
packet.seek(0)
|
||||
overlay_reader = PdfReader(packet)
|
||||
page.merge_page(overlay_reader.pages[0])
|
||||
writer.add_page(page)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
return output_path
|
||||
143
app/engine/pdf_utils.py
Normal file
143
app/engine/pdf_utils.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Utilitaires PDF communs pour Copydev Imposing Tool."""
|
||||
|
||||
from pypdf import PdfReader, PdfWriter, PageObject, Transformation
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.units import mm, inch
|
||||
from reportlab.lib.pagesizes import A4, A3, letter
|
||||
import io
|
||||
import math
|
||||
|
||||
# Tailles de pages predefinies (largeur, hauteur en points)
|
||||
PAGE_SIZES = {
|
||||
"A4 Portrait": (595.28, 841.89),
|
||||
"A4 Paysage": (841.89, 595.28),
|
||||
"A3 Portrait": (841.89, 1190.55),
|
||||
"A3 Paysage": (1190.55, 841.89),
|
||||
"Letter Portrait": (612, 792),
|
||||
"Letter Paysage": (792, 612),
|
||||
"Legal Portrait": (612, 1008),
|
||||
"Legal Paysage": (1008, 612),
|
||||
}
|
||||
|
||||
# Conversion
|
||||
PT_TO_MM = 25.4 / 72
|
||||
MM_TO_PT = 72 / 25.4
|
||||
|
||||
|
||||
def mm_to_pt(val_mm):
|
||||
"""Convertir millimetres en points."""
|
||||
return val_mm * MM_TO_PT
|
||||
|
||||
|
||||
def pt_to_mm(val_pt):
|
||||
"""Convertir points en millimetres."""
|
||||
return val_pt * PT_TO_MM
|
||||
|
||||
|
||||
def get_page_size(page):
|
||||
"""Obtenir la taille d'une page (largeur, hauteur) en points."""
|
||||
box = page.mediabox
|
||||
w = float(box.width)
|
||||
h = float(box.height)
|
||||
return w, h
|
||||
|
||||
|
||||
def create_blank_page(width, height):
|
||||
"""Creer une page blanche avec les dimensions donnees (en points)."""
|
||||
packet = io.BytesIO()
|
||||
c = canvas.Canvas(packet, pagesize=(width, height))
|
||||
c.showPage()
|
||||
c.save()
|
||||
packet.seek(0)
|
||||
reader = PdfReader(packet)
|
||||
return reader.pages[0]
|
||||
|
||||
|
||||
def merge_page_onto(base_page, overlay_page, x=0, y=0, scale=1.0, rotation=0):
|
||||
"""Fusionner une page overlay sur une page de base a une position donnee."""
|
||||
if rotation:
|
||||
overlay_page = overlay_page.rotate(rotation)
|
||||
|
||||
transform = Transformation().scale(scale, scale).translate(x, y)
|
||||
overlay_page.add_transformation(transform)
|
||||
base_page.merge_page(overlay_page)
|
||||
return base_page
|
||||
|
||||
|
||||
def create_crop_marks_page(width, height, positions, mark_length=14.17, mark_offset=7.09, line_width=0.25):
|
||||
"""Creer une page avec des reperes de coupe aux positions donnees.
|
||||
|
||||
positions: liste de tuples (x, y) ou placer les reperes
|
||||
mark_length: longueur des traits (defaut 5mm = 14.17pt)
|
||||
mark_offset: distance du bord de la page (defaut 2.5mm = 7.09pt)
|
||||
"""
|
||||
packet = io.BytesIO()
|
||||
c = canvas.Canvas(packet, pagesize=(width, height))
|
||||
c.setLineWidth(line_width)
|
||||
c.setStrokeColorRGB(0, 0, 0)
|
||||
|
||||
for x, y in positions:
|
||||
# Trait horizontal gauche
|
||||
c.line(x - mark_offset - mark_length, y, x - mark_offset, y)
|
||||
# Trait horizontal droit
|
||||
c.line(x + mark_offset, y, x + mark_offset + mark_length, y)
|
||||
# Trait vertical bas
|
||||
c.line(x, y - mark_offset - mark_length, x, y - mark_offset)
|
||||
# Trait vertical haut
|
||||
c.line(x, y + mark_offset, x, y + mark_offset + mark_length)
|
||||
|
||||
c.showPage()
|
||||
c.save()
|
||||
packet.seek(0)
|
||||
reader = PdfReader(packet)
|
||||
return reader.pages[0]
|
||||
|
||||
|
||||
def smart_crop_marks(sheet_width, sheet_height, grid_positions, cell_width, cell_height,
|
||||
margin_left=0, margin_top=0, mark_length=14.17, mark_offset=7.09):
|
||||
"""Generer des reperes de coupe intelligents pour une grille n-up.
|
||||
|
||||
Evite les chevauchements avec le contenu des pages.
|
||||
"""
|
||||
positions = set()
|
||||
|
||||
for col, row in grid_positions:
|
||||
x = margin_left + col * cell_width
|
||||
y = sheet_height - margin_top - row * cell_height
|
||||
|
||||
# 4 coins de chaque cellule
|
||||
corners = [
|
||||
(x, y),
|
||||
(x + cell_width, y),
|
||||
(x, y - cell_height),
|
||||
(x + cell_width, y - cell_height),
|
||||
]
|
||||
for cx, cy in corners:
|
||||
positions.add((cx, cy))
|
||||
|
||||
return create_crop_marks_page(sheet_width, sheet_height, list(positions),
|
||||
mark_length, mark_offset)
|
||||
|
||||
|
||||
def pad_to_multiple(reader, multiple=4):
|
||||
"""Ajouter des pages blanches pour atteindre un multiple donne."""
|
||||
num_pages = len(reader.pages)
|
||||
remainder = num_pages % multiple
|
||||
if remainder == 0:
|
||||
return reader
|
||||
|
||||
pages_to_add = multiple - remainder
|
||||
writer = PdfWriter()
|
||||
for page in reader.pages:
|
||||
writer.add_page(page)
|
||||
|
||||
last_page = reader.pages[-1]
|
||||
w, h = get_page_size(last_page)
|
||||
|
||||
for _ in range(pages_to_add):
|
||||
writer.add_blank_page(w, h)
|
||||
|
||||
output = io.BytesIO()
|
||||
writer.write(output)
|
||||
output.seek(0)
|
||||
return PdfReader(output)
|
||||
66
app/engine/step_repeat.py
Normal file
66
app/engine/step_repeat.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Moteur Step & Repeat : dupliquer une page plusieurs fois sur une feuille."""
|
||||
|
||||
from pypdf import PdfReader, PdfWriter, PageObject, Transformation
|
||||
from .pdf_utils import get_page_size, smart_crop_marks
|
||||
import math
|
||||
|
||||
|
||||
def step_and_repeat(input_path, output_path, sheet_size=(841.89, 595.28),
|
||||
copies_h=None, copies_v=None, spacing_h=0, spacing_v=0,
|
||||
crop_marks=False):
|
||||
"""Dupliquer chaque page sur une feuille.
|
||||
|
||||
copies_h, copies_v: nombre de copies horizontales/verticales (None = auto)
|
||||
spacing_h, spacing_v: espacement en points
|
||||
"""
|
||||
reader = PdfReader(input_path)
|
||||
sheet_w, sheet_h = sheet_size
|
||||
writer = PdfWriter()
|
||||
|
||||
for page in reader.pages:
|
||||
page_w, page_h = get_page_size(page)
|
||||
|
||||
# Auto-calcul du nombre de copies
|
||||
if copies_h is None:
|
||||
copies_h_calc = max(1, int((sheet_w + spacing_h) / (page_w + spacing_h)))
|
||||
else:
|
||||
copies_h_calc = copies_h
|
||||
|
||||
if copies_v is None:
|
||||
copies_v_calc = max(1, int((sheet_h + spacing_v) / (page_h + spacing_v)))
|
||||
else:
|
||||
copies_v_calc = copies_v
|
||||
|
||||
# Centrage sur la feuille
|
||||
total_w = copies_h_calc * page_w + (copies_h_calc - 1) * spacing_h
|
||||
total_h = copies_v_calc * page_h + (copies_v_calc - 1) * spacing_v
|
||||
start_x = (sheet_w - total_w) / 2
|
||||
start_y = (sheet_h - total_h) / 2
|
||||
|
||||
sheet = PageObject.create_blank_page(width=sheet_w, height=sheet_h)
|
||||
grid_positions = []
|
||||
|
||||
for row in range(copies_v_calc):
|
||||
for col in range(copies_h_calc):
|
||||
x = start_x + col * (page_w + spacing_h)
|
||||
y = start_y + (copies_v_calc - 1 - row) * (page_h + spacing_v)
|
||||
|
||||
overlay = PageObject.create_blank_page(width=sheet_w, height=sheet_h)
|
||||
overlay.merge_page(page)
|
||||
overlay.add_transformation(Transformation().translate(x, y))
|
||||
sheet.merge_page(overlay)
|
||||
grid_positions.append((col, row))
|
||||
|
||||
if crop_marks and grid_positions:
|
||||
marks = smart_crop_marks(
|
||||
sheet_w, sheet_h, grid_positions,
|
||||
page_w + spacing_h, page_h + spacing_v,
|
||||
start_x, sheet_h - start_y - total_h
|
||||
)
|
||||
sheet.merge_page(marks)
|
||||
|
||||
writer.add_page(sheet)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
return output_path
|
||||
341
app/main_window.py
Normal file
341
app/main_window.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""Fenetre principale de Copydev Imposing Tool."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from PyQt6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QLabel, QFileDialog, QMessageBox,
|
||||
QGroupBox, QStatusBar, QMenuBar, QSplitter,
|
||||
QProgressBar)
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QAction
|
||||
|
||||
from app.pdf_viewer import PdfViewer
|
||||
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.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
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Copydev Imposing Tool")
|
||||
self.setMinimumSize(1000, 700)
|
||||
self._current_file = None
|
||||
self._result_file = None
|
||||
self._setup_menu()
|
||||
self._setup_ui()
|
||||
self._setup_statusbar()
|
||||
|
||||
def _setup_menu(self):
|
||||
menubar = self.menuBar()
|
||||
|
||||
# Menu Fichier
|
||||
file_menu = menubar.addMenu("Fichier")
|
||||
|
||||
open_act = QAction("Ouvrir PDF...", self)
|
||||
open_act.setShortcut("Ctrl+O")
|
||||
open_act.triggered.connect(self._open_file)
|
||||
file_menu.addAction(open_act)
|
||||
|
||||
save_act = QAction("Enregistrer le resultat...", self)
|
||||
save_act.setShortcut("Ctrl+S")
|
||||
save_act.triggered.connect(self._save_result)
|
||||
file_menu.addAction(save_act)
|
||||
|
||||
file_menu.addSeparator()
|
||||
|
||||
quit_act = QAction("Quitter", self)
|
||||
quit_act.setShortcut("Ctrl+Q")
|
||||
quit_act.triggered.connect(self.close)
|
||||
file_menu.addAction(quit_act)
|
||||
|
||||
# Menu Aide
|
||||
help_menu = menubar.addMenu("Aide")
|
||||
about_act = QAction("A propos...", self)
|
||||
about_act.triggered.connect(self._show_about)
|
||||
help_menu.addAction(about_act)
|
||||
|
||||
def _setup_ui(self):
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
main_layout = QHBoxLayout(central)
|
||||
|
||||
# === Panneau gauche : boutons ===
|
||||
left_panel = QWidget()
|
||||
left_panel.setFixedWidth(200)
|
||||
left_layout = QVBoxLayout(left_panel)
|
||||
left_layout.setContentsMargins(5, 5, 5, 5)
|
||||
|
||||
# Ouvrir
|
||||
btn_open = QPushButton("Ouvrir PDF...")
|
||||
btn_open.clicked.connect(self._open_file)
|
||||
left_layout.addWidget(btn_open)
|
||||
|
||||
btn_save = QPushButton("Enregistrer resultat...")
|
||||
btn_save.clicked.connect(self._save_result)
|
||||
left_layout.addWidget(btn_save)
|
||||
|
||||
left_layout.addSpacing(10)
|
||||
|
||||
# Imposition
|
||||
grp_impose = QGroupBox("Imposition")
|
||||
il = QVBoxLayout(grp_impose)
|
||||
|
||||
btn_booklet = QPushButton("Creer un livret")
|
||||
btn_booklet.clicked.connect(self._do_booklet)
|
||||
il.addWidget(btn_booklet)
|
||||
|
||||
btn_nup = QPushButton("N pages par feuille")
|
||||
btn_nup.clicked.connect(self._do_nup)
|
||||
il.addWidget(btn_nup)
|
||||
|
||||
btn_repeat = QPushButton("Repetition")
|
||||
btn_repeat.clicked.connect(self._do_step_repeat)
|
||||
il.addWidget(btn_repeat)
|
||||
|
||||
btn_manual = QPushButton("Imposition manuelle")
|
||||
btn_manual.clicked.connect(self._do_manual_impose)
|
||||
il.addWidget(btn_manual)
|
||||
|
||||
btn_join = QPushButton("Joindre deux pages")
|
||||
btn_join.clicked.connect(self._do_join_pages)
|
||||
il.addWidget(btn_join)
|
||||
|
||||
left_layout.addWidget(grp_impose)
|
||||
|
||||
# Pages
|
||||
grp_pages = QGroupBox("Pages")
|
||||
pl = QVBoxLayout(grp_pages)
|
||||
|
||||
btn_blank = QPushButton("Pages blanches")
|
||||
btn_blank.clicked.connect(self._do_insert_blank)
|
||||
pl.addWidget(btn_blank)
|
||||
|
||||
left_layout.addWidget(grp_pages)
|
||||
|
||||
# Annotations
|
||||
grp_annot = QGroupBox("Annotations")
|
||||
al = QVBoxLayout(grp_annot)
|
||||
|
||||
btn_numbers = QPushButton("Numerotation")
|
||||
btn_numbers.clicked.connect(self._do_page_numbers)
|
||||
al.addWidget(btn_numbers)
|
||||
|
||||
btn_mask = QPushButton("Masquage")
|
||||
btn_mask.clicked.connect(self._do_masking)
|
||||
al.addWidget(btn_mask)
|
||||
|
||||
btn_bleeds = QPushButton("Fonds perdus")
|
||||
btn_bleeds.clicked.connect(self._do_bleeds)
|
||||
al.addWidget(btn_bleeds)
|
||||
|
||||
left_layout.addWidget(grp_annot)
|
||||
left_layout.addStretch()
|
||||
|
||||
main_layout.addWidget(left_panel)
|
||||
|
||||
# === Zone centrale : viewer PDF ===
|
||||
self._viewer = PdfViewer()
|
||||
self._viewer.page_changed.connect(self._on_page_changed)
|
||||
main_layout.addWidget(self._viewer, 1)
|
||||
|
||||
def _setup_statusbar(self):
|
||||
self._statusbar = QStatusBar()
|
||||
self.setStatusBar(self._statusbar)
|
||||
self._status_label = QLabel("Pret")
|
||||
self._statusbar.addWidget(self._status_label, 1)
|
||||
self._file_label = QLabel("")
|
||||
self._statusbar.addPermanentWidget(self._file_label)
|
||||
|
||||
def _on_page_changed(self, page, total):
|
||||
pass
|
||||
|
||||
def _open_file(self):
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Ouvrir un fichier PDF", "", "PDF (*.pdf)"
|
||||
)
|
||||
if path:
|
||||
self._current_file = path
|
||||
self._result_file = None
|
||||
self._viewer.load_pdf(path)
|
||||
self._file_label.setText(os.path.basename(path))
|
||||
self._status_label.setText(f"Fichier charge : {os.path.basename(path)}")
|
||||
|
||||
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"
|
||||
import shutil
|
||||
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
|
||||
|
||||
def _show_result(self, output_path):
|
||||
self._result_file = output_path
|
||||
self._viewer.load_pdf(output_path)
|
||||
self._status_label.setText("Traitement termine")
|
||||
|
||||
def _do_booklet(self):
|
||||
if not self._require_file():
|
||||
return
|
||||
dlg = BookletDialog(self)
|
||||
if dlg.exec():
|
||||
settings = dlg.get_settings()
|
||||
output = self._make_temp()
|
||||
try:
|
||||
create_booklet(self._current_file, output, **settings)
|
||||
self._show_result(output)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Erreur", str(e))
|
||||
|
||||
def _do_nup(self):
|
||||
if not self._require_file():
|
||||
return
|
||||
dlg = NupDialog(self)
|
||||
if dlg.exec():
|
||||
settings = dlg.get_settings()
|
||||
output = self._make_temp()
|
||||
try:
|
||||
nup_pages(self._current_file, output, **settings)
|
||||
self._show_result(output)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Erreur", str(e))
|
||||
|
||||
def _do_step_repeat(self):
|
||||
if not self._require_file():
|
||||
return
|
||||
dlg = StepRepeatDialog(self)
|
||||
if dlg.exec():
|
||||
settings = dlg.get_settings()
|
||||
output = self._make_temp()
|
||||
try:
|
||||
step_and_repeat(self._current_file, output, **settings)
|
||||
self._show_result(output)
|
||||
except Exception as e:
|
||||
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():
|
||||
settings = dlg.get_settings()
|
||||
output = self._make_temp()
|
||||
try:
|
||||
manual_impose(self._current_file, output, **settings)
|
||||
self._show_result(output)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Erreur", str(e))
|
||||
|
||||
def _do_join_pages(self):
|
||||
if not self._require_file():
|
||||
return
|
||||
dlg = JoinPagesDialog(self)
|
||||
if dlg.exec():
|
||||
settings = dlg.get_settings()
|
||||
output = self._make_temp()
|
||||
try:
|
||||
join_pages(self._current_file, output, **settings)
|
||||
self._show_result(output)
|
||||
except Exception as e:
|
||||
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():
|
||||
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:
|
||||
QMessageBox.critical(self, "Erreur", str(e))
|
||||
|
||||
def _do_page_numbers(self):
|
||||
if not self._require_file():
|
||||
return
|
||||
dlg = PageNumbersDialog(self)
|
||||
if dlg.exec():
|
||||
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:
|
||||
QMessageBox.critical(self, "Erreur", str(e))
|
||||
|
||||
def _do_masking(self):
|
||||
if not self._require_file():
|
||||
return
|
||||
dlg = MaskingDialog(self)
|
||||
if dlg.exec():
|
||||
settings = dlg.get_settings()
|
||||
output = self._make_temp()
|
||||
try:
|
||||
apply_masking(self._current_file, output, **settings)
|
||||
self._show_result(output)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Erreur", str(e))
|
||||
|
||||
def _do_bleeds(self):
|
||||
if not self._require_file():
|
||||
return
|
||||
dlg = BleedsDialog(self)
|
||||
if dlg.exec():
|
||||
settings = dlg.get_settings()
|
||||
output = self._make_temp()
|
||||
try:
|
||||
define_bleeds(self._current_file, output, **settings)
|
||||
self._show_result(output)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Erreur", str(e))
|
||||
|
||||
def _show_about(self):
|
||||
QMessageBox.about(
|
||||
self,
|
||||
"A propos",
|
||||
"Copydev Imposing Tool\n\n"
|
||||
"Outil d'imposition PDF\n"
|
||||
"Remplace Quite Imposing Plus\n\n"
|
||||
"Copydev 2026"
|
||||
)
|
||||
178
app/pdf_viewer.py
Normal file
178
app/pdf_viewer.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Widget de visualisation PDF avec pypdfium2."""
|
||||
|
||||
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QSpinBox, QScrollArea, QSizePolicy)
|
||||
from PyQt6.QtGui import QImage, QPixmap
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
import pypdfium2 as pdfium
|
||||
|
||||
|
||||
class PdfViewer(QWidget):
|
||||
"""Widget d'apercu PDF avec navigation et zoom."""
|
||||
|
||||
page_changed = pyqtSignal(int, int) # page_num, total_pages
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._pdf = None
|
||||
self._current_page = 0
|
||||
self._total_pages = 0
|
||||
self._zoom = 1.0
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Zone d'affichage avec scroll
|
||||
self._scroll = QScrollArea()
|
||||
self._scroll.setWidgetResizable(True)
|
||||
self._scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self._image_label = QLabel()
|
||||
self._image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._image_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
|
||||
self._scroll.setWidget(self._image_label)
|
||||
layout.addWidget(self._scroll, 1)
|
||||
|
||||
# Barre de navigation
|
||||
nav = QHBoxLayout()
|
||||
|
||||
self._btn_prev = QPushButton("<")
|
||||
self._btn_prev.setFixedWidth(40)
|
||||
self._btn_prev.clicked.connect(self._prev_page)
|
||||
nav.addWidget(self._btn_prev)
|
||||
|
||||
self._page_spin = QSpinBox()
|
||||
self._page_spin.setMinimum(1)
|
||||
self._page_spin.setMaximum(1)
|
||||
self._page_spin.valueChanged.connect(self._go_to_page)
|
||||
nav.addWidget(self._page_spin)
|
||||
|
||||
self._label_total = QLabel("/ 0")
|
||||
nav.addWidget(self._label_total)
|
||||
|
||||
self._btn_next = QPushButton(">")
|
||||
self._btn_next.setFixedWidth(40)
|
||||
self._btn_next.clicked.connect(self._next_page)
|
||||
nav.addWidget(self._btn_next)
|
||||
|
||||
nav.addSpacing(20)
|
||||
|
||||
self._btn_zoom_out = QPushButton("-")
|
||||
self._btn_zoom_out.setFixedWidth(30)
|
||||
self._btn_zoom_out.clicked.connect(self._zoom_out)
|
||||
nav.addWidget(self._btn_zoom_out)
|
||||
|
||||
self._label_zoom = QLabel("100%")
|
||||
self._label_zoom.setFixedWidth(45)
|
||||
self._label_zoom.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
nav.addWidget(self._label_zoom)
|
||||
|
||||
self._btn_zoom_in = QPushButton("+")
|
||||
self._btn_zoom_in.setFixedWidth(30)
|
||||
self._btn_zoom_in.clicked.connect(self._zoom_in)
|
||||
nav.addWidget(self._btn_zoom_in)
|
||||
|
||||
self._btn_fit = QPushButton("Ajuster")
|
||||
self._btn_fit.clicked.connect(self._zoom_fit)
|
||||
nav.addWidget(self._btn_fit)
|
||||
|
||||
nav.addStretch()
|
||||
layout.addLayout(nav)
|
||||
|
||||
def load_pdf(self, path):
|
||||
"""Charger un fichier PDF."""
|
||||
try:
|
||||
self._pdf = pdfium.PdfDocument(path)
|
||||
self._total_pages = len(self._pdf)
|
||||
self._current_page = 0
|
||||
self._page_spin.setMaximum(self._total_pages)
|
||||
self._page_spin.setValue(1)
|
||||
self._label_total.setText(f"/ {self._total_pages}")
|
||||
self._render_page()
|
||||
except Exception as e:
|
||||
self._image_label.setText(f"Erreur: {e}")
|
||||
|
||||
def load_pdf_bytes(self, data):
|
||||
"""Charger un PDF depuis des bytes."""
|
||||
try:
|
||||
self._pdf = pdfium.PdfDocument(data)
|
||||
self._total_pages = len(self._pdf)
|
||||
self._current_page = 0
|
||||
self._page_spin.setMaximum(self._total_pages)
|
||||
self._page_spin.setValue(1)
|
||||
self._label_total.setText(f"/ {self._total_pages}")
|
||||
self._render_page()
|
||||
except Exception as e:
|
||||
self._image_label.setText(f"Erreur: {e}")
|
||||
|
||||
def _render_page(self):
|
||||
"""Afficher la page courante."""
|
||||
if not self._pdf or self._current_page >= self._total_pages:
|
||||
return
|
||||
|
||||
page = self._pdf[self._current_page]
|
||||
# Rendu a 150 DPI * zoom
|
||||
scale = 150 / 72 * self._zoom
|
||||
bitmap = page.render(scale=scale)
|
||||
pil_image = bitmap.to_pil()
|
||||
|
||||
# Convertir PIL -> QImage -> QPixmap
|
||||
data = pil_image.tobytes("raw", "RGBA")
|
||||
qimage = QImage(data, pil_image.width, pil_image.height, QImage.Format.Format_RGBA8888)
|
||||
pixmap = QPixmap.fromImage(qimage)
|
||||
|
||||
self._image_label.setPixmap(pixmap)
|
||||
self._image_label.adjustSize()
|
||||
self._label_zoom.setText(f"{int(self._zoom * 100)}%")
|
||||
self.page_changed.emit(self._current_page + 1, self._total_pages)
|
||||
|
||||
def _prev_page(self):
|
||||
if self._current_page > 0:
|
||||
self._current_page -= 1
|
||||
self._page_spin.setValue(self._current_page + 1)
|
||||
|
||||
def _next_page(self):
|
||||
if self._current_page < self._total_pages - 1:
|
||||
self._current_page += 1
|
||||
self._page_spin.setValue(self._current_page + 1)
|
||||
|
||||
def _go_to_page(self, value):
|
||||
page = value - 1
|
||||
if 0 <= page < self._total_pages and page != self._current_page:
|
||||
self._current_page = page
|
||||
self._render_page()
|
||||
|
||||
def _zoom_in(self):
|
||||
self._zoom = min(self._zoom * 1.25, 5.0)
|
||||
self._render_page()
|
||||
|
||||
def _zoom_out(self):
|
||||
self._zoom = max(self._zoom / 1.25, 0.25)
|
||||
self._render_page()
|
||||
|
||||
def _zoom_fit(self):
|
||||
"""Ajuster le zoom pour que la page tienne dans la zone visible."""
|
||||
if not self._pdf or self._current_page >= self._total_pages:
|
||||
return
|
||||
page = self._pdf[self._current_page]
|
||||
pw, ph = page.get_size()
|
||||
vw = self._scroll.viewport().width() - 20
|
||||
vh = self._scroll.viewport().height() - 20
|
||||
scale_x = vw / pw
|
||||
scale_y = vh / ph
|
||||
self._zoom = min(scale_x, scale_y) * (72 / 150)
|
||||
self._render_page()
|
||||
|
||||
def refresh(self):
|
||||
"""Rafraichir l'affichage."""
|
||||
self._render_page()
|
||||
|
||||
@property
|
||||
def current_page(self):
|
||||
return self._current_page
|
||||
|
||||
@property
|
||||
def total_pages(self):
|
||||
return self._total_pages
|
||||
0
app/presets/__init__.py
Normal file
0
app/presets/__init__.py
Normal file
22
build.bat
Normal file
22
build.bat
Normal file
@@ -0,0 +1,22 @@
|
||||
@echo off
|
||||
REM Script de build Windows pour Copydev Imposing Tool
|
||||
REM Prerequis : Python 3.12+, pip install -r requirements.txt
|
||||
|
||||
echo Installation des dependances...
|
||||
pip install -r requirements.txt
|
||||
|
||||
echo.
|
||||
echo Construction de l'executable...
|
||||
pyinstaller --onefile --noconsole --name CopydevImposingTool main.py ^
|
||||
--hidden-import pypdf ^
|
||||
--hidden-import reportlab ^
|
||||
--hidden-import reportlab.pdfgen ^
|
||||
--hidden-import reportlab.lib ^
|
||||
--hidden-import reportlab.lib.pagesizes ^
|
||||
--hidden-import reportlab.lib.units ^
|
||||
--hidden-import pypdfium2 ^
|
||||
--hidden-import pypdfium2._helpers
|
||||
|
||||
echo.
|
||||
echo Termine ! L'executable est dans le dossier dist/
|
||||
pause
|
||||
56
build/build.spec
Normal file
56
build/build.spec
Normal file
@@ -0,0 +1,56 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
# PyInstaller spec pour Copydev Imposing Tool
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
block_cipher = None
|
||||
|
||||
a = Analysis(
|
||||
['../main.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
hiddenimports=[
|
||||
'pypdf',
|
||||
'reportlab',
|
||||
'reportlab.pdfgen',
|
||||
'reportlab.lib',
|
||||
'reportlab.lib.pagesizes',
|
||||
'reportlab.lib.units',
|
||||
'pypdfium2',
|
||||
'pypdfium2._helpers',
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name='CopydevImposingTool',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
25
main.py
Normal file
25
main.py
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Copydev Imposing Tool — Point d'entree."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Ajouter le repertoire racine au path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from app.main_window import MainWindow
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Copydev Imposing Tool")
|
||||
app.setOrganizationName("Copydev")
|
||||
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
PyQt6>=6.6
|
||||
pypdf>=4.0
|
||||
reportlab>=4.0
|
||||
pypdfium2>=4.0
|
||||
PyInstaller>=6.0
|
||||
Reference in New Issue
Block a user