V3.6.0 — Demonter pages, masquage visuel, regles, bleeds, corrections

- Demonter les pages : couper en 2 sans reordonner (sous Delivretiser)
- Masquage visuel : selection au curseur sur l'apercu + plage de pages
- Regles horizontale/verticale en mm (menu Affichage)
- Fonds perdus : filigrane rouge pour visualiser les bleeds
- Reperes de coupe : marques a l'exterieur (page agrandie), 2 traits/coin
- Numerotation : couleur configurable via selecteur
- Decalage : Trim & Shift renomme en francais, creep -> chasse
- Recadrages multiples cumulatifs (plages differentes)
- Fix spinbox format personnalise (fleches aleatoires)
- Fix combo livret : option Automatique preservee

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jules
2026-03-29 22:12:54 +02:00
parent f7ad806b26
commit 202834a794
13 changed files with 675 additions and 113 deletions

View File

@@ -64,12 +64,18 @@ class CustomSizeDialog(QDialog):
self.spin_w.setRange(10, 5000)
self.spin_w.setValue(210)
self.spin_w.setSuffix(" mm")
self.spin_w.setDecimals(1)
self.spin_w.setSingleStep(1.0)
self.spin_w.setMinimumWidth(100)
gl.addWidget(self.spin_w)
gl.addWidget(QLabel("Hauteur :"))
self.spin_h = QDoubleSpinBox()
self.spin_h.setRange(10, 5000)
self.spin_h.setValue(297)
self.spin_h.setSuffix(" mm")
self.spin_h.setDecimals(1)
self.spin_h.setSingleStep(1.0)
self.spin_h.setMinimumWidth(100)
gl.addWidget(self.spin_h)
layout.addWidget(grp)

View File

@@ -12,10 +12,27 @@ ADD_LABEL = "Ajouter un format..."
def populate_format_combo(combo, default="A4 Paysage"):
"""Remplir un combo avec les formats standard + custom + options."""
"""Remplir un combo avec les formats standard + custom + options.
Si default n'est pas un format standard, il est conserve en tete du combo.
"""
combo.blockSignals(True)
# Sauvegarder l'item par defaut s'il n'est pas un format standard
keep_first = None
if default and default not in PAGE_SIZES:
# Verifier s'il est deja dans le combo avec des data
idx = combo.findText(default)
if idx >= 0:
keep_first = (default, combo.itemData(idx))
combo.clear()
# Re-ajouter l'item special en tete si necessaire
if keep_first:
combo.addItem(keep_first[0], keep_first[1])
combo.insertSeparator(combo.count())
# Formats standard
for name in PAGE_SIZES:
combo.addItem(name, PAGE_SIZES[name])

View File

@@ -1,61 +1,109 @@
"""Dialogue Masquage (rectangles blancs)."""
"""Dialogue Masquage (rectangles blancs) — avec selection visuelle."""
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QGroupBox, QComboBox, QDoubleSpinBox,
QDialogButtonBox, QPushButton, QListWidget,
QListWidgetItem)
from app.engine.pdf_utils import mm_to_pt
QLineEdit, QRadioButton)
from PyQt6.QtCore import Qt
from app.engine.pdf_utils import mm_to_pt, pt_to_mm
class MaskingDialog(QDialog):
def __init__(self, parent=None):
def __init__(self, page_width_pt=595, page_height_pt=842, total_pages=1,
current_page=0, viewer=None, parent=None):
super().__init__(parent)
self.setWindowTitle("Masquage (ruban blanc)")
self.setMinimumWidth(450)
self.setFixedWidth(380)
self.setModal(False)
self.setWindowFlags(self.windowFlags() | Qt.WindowType.Tool)
self._page_w = page_width_pt
self._page_h = page_height_pt
self._total_pages = total_pages
self._current_page = current_page
self._viewer = viewer
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)
# Info
pw_mm = pt_to_mm(self._page_w)
ph_mm = pt_to_mm(self._page_h)
info = QLabel(f"Page : {pw_mm:.1f} x {ph_mm:.1f} mm")
info.setStyleSheet("color: #888; font-size: 11px;")
layout.addWidget(info)
rl.addWidget(QLabel("X:"))
# Bouton selection visuelle
if self._viewer:
sel_layout = QHBoxLayout()
self.btn_select = QPushButton("Selectionner la zone sur l'apercu")
self.btn_select.setCheckable(True)
self.btn_select.setStyleSheet("""
QPushButton { background: #00b4d8; color: white; padding: 6px 14px;
border-radius: 6px; font-weight: bold; font-size: 11px; }
QPushButton:checked { background: #e74c3c; }
QPushButton:hover { background: #0096b7; }
QPushButton:checked:hover { background: #c0392b; }
""")
self.btn_select.toggled.connect(self._toggle_select_mode)
sel_layout.addWidget(self.btn_select)
sel_layout.addStretch()
layout.addLayout(sel_layout)
self._viewer.selection_changed.connect(self._on_selection_changed)
# Coordonnees manuelles
grp_rect = QGroupBox("Zone de masquage (mm)")
rl = QVBoxLayout(grp_rect)
row1 = QHBoxLayout()
row1.addWidget(QLabel("X :"))
self.spin_x = QDoubleSpinBox()
self.spin_x.setRange(0, 1000)
self.spin_x.setRange(0, 2000)
self.spin_x.setSuffix(" mm")
rl.addWidget(self.spin_x)
rl.addWidget(QLabel("Y:"))
row1.addWidget(self.spin_x)
row1.addWidget(QLabel("Y :"))
self.spin_y = QDoubleSpinBox()
self.spin_y.setRange(0, 1000)
self.spin_y.setRange(0, 2000)
self.spin_y.setSuffix(" mm")
rl.addWidget(self.spin_y)
row1.addWidget(self.spin_y)
rl.addLayout(row1)
rl.addWidget(QLabel("L:"))
row2 = QHBoxLayout()
row2.addWidget(QLabel("Largeur :"))
self.spin_w = QDoubleSpinBox()
self.spin_w.setRange(1, 1000)
self.spin_w.setRange(1, 2000)
self.spin_w.setValue(50)
self.spin_w.setSuffix(" mm")
rl.addWidget(self.spin_w)
rl.addWidget(QLabel("H:"))
row2.addWidget(self.spin_w)
row2.addWidget(QLabel("Hauteur :"))
self.spin_h = QDoubleSpinBox()
self.spin_h.setRange(1, 1000)
self.spin_h.setRange(1, 2000)
self.spin_h.setValue(10)
self.spin_h.setSuffix(" mm")
rl.addWidget(self.spin_h)
row2.addWidget(self.spin_h)
rl.addLayout(row2)
layout.addWidget(grp_rect)
# Boutons ajouter/supprimer
# Bouton ajouter
btn_layout = QHBoxLayout()
btn_add = QPushButton("Ajouter")
btn_add.clicked.connect(self._add_rect)
btn_layout.addWidget(btn_add)
self.btn_add = QPushButton("Ajouter ce masque")
self.btn_add.setStyleSheet("""
QPushButton { background: #2ecc71; color: white; padding: 6px 14px;
border-radius: 6px; font-weight: bold; }
QPushButton:hover { background: #27ae60; }
""")
self.btn_add.clicked.connect(self._add_rect)
btn_layout.addWidget(self.btn_add)
btn_del = QPushButton("Supprimer")
btn_del.setStyleSheet("""
QPushButton { background: #e8e8e8; color: #555; padding: 6px 14px;
border-radius: 6px; border: 1px solid #ccc; }
QPushButton:hover { background: #ddd; }
""")
btn_del.clicked.connect(self._del_rect)
btn_layout.addWidget(btn_del)
btn_layout.addStretch()
@@ -63,15 +111,31 @@ class MaskingDialog(QDialog):
# Liste des rectangles
self.list_rects = QListWidget()
self.list_rects.setMaximumHeight(100)
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)
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(
@@ -81,32 +145,128 @@ class MaskingDialog(QDialog):
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _toggle_select_mode(self, checked):
if self._viewer:
self._viewer.set_select_mode(checked)
if checked:
self.btn_select.setText("Mode selection actif — dessiner la zone")
else:
self.btn_select.setText("Selectionner la zone sur l'apercu")
def _on_selection_changed(self, left, top, right, bottom):
"""Recoit les ratios de selection du viewer."""
pw_mm = pt_to_mm(self._page_w)
ph_mm = pt_to_mm(self._page_h)
x_mm = left * pw_mm
y_mm = top * ph_mm
w_mm = (right - left) * pw_mm
h_mm = (bottom - top) * ph_mm
if w_mm > 0 and h_mm > 0:
self.spin_x.blockSignals(True)
self.spin_y.blockSignals(True)
self.spin_w.blockSignals(True)
self.spin_h.blockSignals(True)
self.spin_x.setValue(x_mm)
self.spin_y.setValue(y_mm)
self.spin_w.setValue(w_mm)
self.spin_h.setValue(h_mm)
self.spin_x.blockSignals(False)
self.spin_y.blockSignals(False)
self.spin_w.blockSignals(False)
self.spin_h.blockSignals(False)
def _add_rect(self):
x_mm = self.spin_x.value()
y_mm = self.spin_y.value()
w_mm = self.spin_w.value()
h_mm = self.spin_h.value()
# Convertir Y : dans le viewer le Y part du haut, dans le PDF du bas
page_h_mm = pt_to_mm(self._page_h)
pdf_y_mm = page_h_mm - y_mm - h_mm
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()),
"x": mm_to_pt(x_mm),
"y": mm_to_pt(pdf_y_mm),
"width": mm_to_pt(w_mm),
"height": mm_to_pt(h_mm),
}
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"
f"X={x_mm:.1f} Y={y_mm:.1f} L={w_mm:.1f} H={h_mm:.1f} mm"
)
# Nettoyer la selection pour en dessiner une autre
if self._viewer:
self._viewer.clear_selection()
def _del_rect(self):
row = self.list_rects.currentRow()
if row >= 0:
self.list_rects.takeItem(row)
self._rectangles.pop(row)
def _parse_range(self):
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 closeEvent(self, event):
if self._viewer:
self._viewer.set_select_mode(False)
self._viewer.clear_selection()
super().closeEvent(event)
def reject(self):
if self._viewer:
self._viewer.set_select_mode(False)
self._viewer.clear_selection()
super().reject()
def get_settings(self):
apply_map = {
"Toutes les pages": "all",
"Pages paires": "even",
"Pages impaires": "odd"
}
if self._viewer:
self._viewer.set_select_mode(False)
self._viewer.clear_selection()
# Determiner apply_to
if self.rb_current.isChecked():
apply_to = self._current_page + 1 # 1-based pour le moteur
elif self.rb_all.isChecked():
apply_to = "all"
elif self.rb_even.isChecked():
apply_to = "even"
elif self.rb_odd.isChecked():
apply_to = "odd"
else:
# Plage personnalisee
pages = self._parse_range()
if pages:
apply_to = pages # liste d'indices 0-based
else:
apply_to = "all"
return {
"rectangles": self._rectangles,
"apply_to": apply_map[self.combo_apply.currentText()],
"apply_to": apply_to,
}

View File

@@ -2,7 +2,9 @@
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QGroupBox, QComboBox, QSpinBox, QLineEdit,
QDialogButtonBox, QDoubleSpinBox)
QDialogButtonBox, QDoubleSpinBox, QPushButton,
QColorDialog)
from PyQt6.QtGui import QColor
from reportlab.lib.units import mm
@@ -11,6 +13,7 @@ class PageNumbersDialog(QDialog):
super().__init__(parent)
self.setWindowTitle("Numerotation de pages")
self.setMinimumWidth(400)
self._color = QColor(0, 0, 0)
self._setup_ui()
def _setup_ui(self):
@@ -45,6 +48,17 @@ class PageNumbersDialog(QDialog):
fl.addWidget(self.spin_size)
layout.addWidget(grp_font)
# Couleur
color_layout = QHBoxLayout()
color_layout.addWidget(QLabel("Couleur :"))
self.btn_color = QPushButton()
self.btn_color.setFixedSize(60, 28)
self._update_color_button()
self.btn_color.clicked.connect(self._pick_color)
color_layout.addWidget(self.btn_color)
color_layout.addStretch()
layout.addLayout(color_layout)
# Texte
grp_text = QGroupBox("Texte")
tl = QHBoxLayout(grp_text)
@@ -100,6 +114,17 @@ class PageNumbersDialog(QDialog):
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _pick_color(self):
color = QColorDialog.getColor(self._color, self, "Couleur de la numerotation")
if color.isValid():
self._color = color
self._update_color_button()
def _update_color_button(self):
self.btn_color.setStyleSheet(
f"background-color: {self._color.name()}; border: 1px solid #999; border-radius: 4px;"
)
def get_settings(self):
apply_map = {
"Toutes les pages": "all",
@@ -110,7 +135,7 @@ class PageNumbersDialog(QDialog):
"position": self.combo_pos.currentText(),
"font_name": self.combo_font.currentText(),
"font_size": self.spin_size.value(),
"color": (0, 0, 0),
"color": (self._color.redF(), self._color.greenF(), self._color.blueF()),
"prefix": self.edit_prefix.text(),
"suffix": self.edit_suffix.text(),
"start_number": self.spin_start.value(),

View File

@@ -1,4 +1,4 @@
"""Dialogue Trim & Shift."""
"""Dialogue Decalage du contenu."""
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QGroupBox, QDoubleSpinBox, QCheckBox,
@@ -9,7 +9,7 @@ from app.engine.pdf_utils import mm_to_pt
class TrimShiftDialog(QDialog):
def __init__(self, total_pages=1, parent=None):
super().__init__(parent)
self.setWindowTitle("Trim && Shift — Decaler le contenu")
self.setWindowTitle("Decalage du contenu")
self.setMinimumWidth(420)
self._total_pages = total_pages
self._setup_ui()
@@ -55,10 +55,10 @@ class TrimShiftDialog(QDialog):
self.chk_different.toggled.connect(self.spin_x_even.setEnabled)
self.chk_different.toggled.connect(self.spin_y_even.setEnabled)
# Creep
grp_creep = QGroupBox("Compensation de creep (livrets)")
# Chasse (creep)
grp_creep = QGroupBox("Compensation de chasse (livrets)")
cl = QHBoxLayout(grp_creep)
cl.addWidget(QLabel("Creep max :"))
cl.addWidget(QLabel("Chasse max :"))
self.spin_creep = QDoubleSpinBox()
self.spin_creep.setRange(0, 20)
self.spin_creep.setSuffix(" mm")

View File

@@ -5,36 +5,50 @@ 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):
crop_left=0, crop_right=0, pages=None, per_page_settings=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.
per_page_settings : dict {page_index: {"crop_top", "crop_bottom", "crop_left", "crop_right"}}
Si fourni, applique des reglages differents par page.
"""
reader = PdfReader(input_path)
writer = PdfWriter()
if pages is None:
if pages is None and per_page_settings 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
# Verifier si on a des reglages specifiques pour cette page
if per_page_settings and i in per_page_settings:
ps = per_page_settings[i]
ct = ps.get("crop_top", 0)
cb = ps.get("crop_bottom", 0)
cl = ps.get("crop_left", 0)
cr = ps.get("crop_right", 0)
elif pages is not None and i in pages:
ct, cb, cl, cr = crop_top, crop_bottom, crop_left, crop_right
else:
writer.add_page(page)
continue
# Securite
if new_right <= new_left or new_top <= new_bottom:
writer.add_page(page)
continue
w, h = get_page_size(page)
new_left = float(page.mediabox.left) + cl
new_bottom = float(page.mediabox.bottom) + cb
new_right = float(page.mediabox.right) - cr
new_top = float(page.mediabox.top) - ct
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
# 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)

View File

@@ -1,6 +1,6 @@
"""Moteur Reperes de coupe autonomes."""
from pypdf import PdfReader, PdfWriter
from pypdf import PdfReader, PdfWriter, PageObject, Transformation
from reportlab.pdfgen import canvas
from .pdf_utils import get_page_size
import io
@@ -8,11 +8,18 @@ import io
def add_crop_marks(input_path, output_path, mark_length=14.17, mark_offset=7.09,
line_width=0.25, color=(0, 0, 0), center_marks=False,
apply_to="all", current_page=0):
"""Ajouter des reperes de coupe sur les pages."""
apply_to="all", current_page=0, **_kwargs):
"""Ajouter des reperes de coupe sur les pages.
Les marques sont placees a l'exterieur de la zone d'impression.
Chaque coin a 2 traits (1 horizontal + 1 vertical) formant un L.
La page est agrandie pour contenir les marques.
"""
reader = PdfReader(input_path)
writer = PdfWriter()
margin = mark_offset + mark_length
for i, page in enumerate(reader.pages):
page_num = i + 1
should_apply = False
@@ -27,46 +34,62 @@ def add_crop_marks(input_path, output_path, mark_length=14.17, mark_offset=7.09,
if should_apply:
w, h = get_page_size(page)
# Canvas agrandi pour inclure les marques
cw = w + 2 * margin
ch = h + 2 * margin
packet = io.BytesIO()
c = canvas.Canvas(packet, pagesize=(w, h))
c = canvas.Canvas(packet, pagesize=(cw, ch))
c.setLineWidth(line_width)
c.setStrokeColorRGB(*color)
# 4 coins
corners = [(0, 0), (w, 0), (0, h), (w, h)]
for cx, cy in corners:
# Traits horizontaux
if cx == 0:
c.line(cx - mark_offset - mark_length, cy, cx - mark_offset, cy)
else:
c.line(cx + mark_offset, cy, cx + mark_offset + mark_length, cy)
# Traits verticaux
if cy == 0:
c.line(cx, cy - mark_offset - mark_length, cx, cy - mark_offset)
else:
c.line(cx, cy + mark_offset, cx, cy + mark_offset + mark_length)
# Les coordonnees de la zone d'impression dans le canvas agrandi
ox, oy = margin, margin # origine de la page originale
# 4 coins — 2 traits par coin (L exterieur)
# Bas-gauche
c.line(ox - mark_offset - mark_length, oy, ox - mark_offset, oy)
c.line(ox, oy - mark_offset - mark_length, ox, oy - mark_offset)
# Bas-droite
c.line(ox + w + mark_offset, oy, ox + w + mark_offset + mark_length, oy)
c.line(ox + w, oy - mark_offset - mark_length, ox + w, oy - mark_offset)
# Haut-gauche
c.line(ox - mark_offset - mark_length, oy + h, ox - mark_offset, oy + h)
c.line(ox, oy + h + mark_offset, ox, oy + h + mark_offset + mark_length)
# Haut-droite
c.line(ox + w + mark_offset, oy + h, ox + w + mark_offset + mark_length, oy + h)
c.line(ox + w, oy + h + mark_offset, ox + w, oy + h + mark_offset + mark_length)
# Reperes de centrage
if center_marks:
mid_x = w / 2
mid_y = h / 2
mid_x = ox + w / 2
mid_y = oy + h / 2
ml = mark_length * 0.7
# Haut
c.line(mid_x, h + mark_offset, mid_x, h + mark_offset + ml)
c.line(mid_x, oy + h + mark_offset, mid_x, oy + h + mark_offset + ml)
# Bas
c.line(mid_x, -mark_offset, mid_x, -mark_offset - ml)
c.line(mid_x, oy - mark_offset, mid_x, oy - mark_offset - ml)
# Gauche
c.line(-mark_offset, mid_y, -mark_offset - ml, mid_y)
c.line(ox - mark_offset, mid_y, ox - mark_offset - ml, mid_y)
# Droite
c.line(w + mark_offset, mid_y, w + mark_offset + ml, mid_y)
c.line(ox + w + mark_offset, mid_y, ox + w + mark_offset + ml, mid_y)
c.showPage()
c.save()
packet.seek(0)
overlay = PdfReader(packet)
page.merge_page(overlay.pages[0])
overlay_reader = PdfReader(packet)
overlay_page = overlay_reader.pages[0]
writer.add_page(page)
# Creer une nouvelle page agrandie
new_page = PageObject.create_blank_page(width=cw, height=ch)
# Placer la page originale au centre (decalee de margin)
page.add_transformation(Transformation().translate(margin, margin))
new_page.merge_page(page)
# Ajouter les marques de coupe par-dessus
new_page.merge_page(overlay_page)
writer.add_page(new_page)
else:
writer.add_page(page)
with open(output_path, "wb") as f:
writer.write(f)

39
app/engine/disassemble.py Normal file
View File

@@ -0,0 +1,39 @@
"""Moteur Demonter les pages — couper en 2 sans reordonner."""
from pypdf import PdfReader, PdfWriter, PageObject, Transformation
from .pdf_utils import get_page_size
def disassemble_pages(input_path, output_path):
"""Demonter un PDF : couper chaque page en 2 moities (gauche puis droite).
Contrairement a Delivretiser, les pages ne sont PAS remises dans l'ordre
du livret. On obtient simplement 2x plus de pages dans l'ordre de lecture.
"""
reader = PdfReader(input_path)
writer = PdfWriter()
for page in reader.pages:
w, h = get_page_size(page)
half_w = w / 2
# Moitie gauche
left_page = PageObject.create_blank_page(width=half_w, height=h)
left_overlay = PageObject.create_blank_page(width=half_w, height=h)
left_overlay.merge_page(page)
left_overlay.mediabox.upper_right = (half_w, h)
left_page.merge_page(left_overlay)
writer.add_page(left_page)
# Moitie droite
right_page = PageObject.create_blank_page(width=half_w, height=h)
right_overlay = PageObject.create_blank_page(width=half_w, height=h)
right_overlay.merge_page(page)
right_overlay.add_transformation(Transformation().translate(-half_w, 0))
right_overlay.mediabox.upper_right = (half_w, h)
right_page.merge_page(right_overlay)
writer.add_page(right_page)
with open(output_path, "wb") as f:
writer.write(f)
return output_path

View File

@@ -10,7 +10,8 @@ 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)
apply_to: "all", "even", "odd", un numero de page (int, 1-based),
ou une liste d'indices (0-based)
"""
reader = PdfReader(input_path)
writer = PdfWriter()
@@ -27,6 +28,8 @@ def apply_masking(input_path, output_path, rectangles, apply_to="all"):
should_apply = page_num % 2 != 0
elif isinstance(apply_to, int):
should_apply = page_num == apply_to
elif isinstance(apply_to, list):
should_apply = i in apply_to
if should_apply and rectangles:
w, h = get_page_size(page)

View File

@@ -40,6 +40,7 @@ from app.engine.crop_marks import add_crop_marks, remove_crop_marks
from app.engine.crop import crop_pages
from app.engine.add_content import add_text_to_pages, add_image_to_pages
from app.engine.unbooklet import unbooklet
from app.engine.disassemble import disassemble_pages
from app.engine.trim_shift import trim_and_shift
from app.engine.extract_content import extract_text_from_page, extract_images_from_page
from app.dialogs.trim_shift_dialog import TrimShiftDialog
@@ -63,6 +64,7 @@ class MainWindow(QMainWindow):
self._result_file = None
self._original_file = None
self._crop_settings = {}
self._bleed_settings = None
self._undo_stack = [] # pile de fichiers pour annuler
self._redo_stack = []
@@ -221,11 +223,12 @@ class MainWindow(QMainWindow):
self._add_action(impose_menu, "Joindre deux pages", None, self._do_join_pages)
impose_menu.addSeparator()
self._add_action(impose_menu, "Delivretiser", None, self._do_unbooklet)
self._add_action(impose_menu, "Demonter les pages", None, self._do_disassemble)
# Pages
pages_menu = menubar.addMenu("Pages")
self._add_action(pages_menu, "Recadrer...", None, self._do_crop)
self._add_action(pages_menu, "Trim && Shift...", None, self._do_trim_shift)
self._add_action(pages_menu, "Decalage...", None, self._do_trim_shift)
pages_menu.addSeparator()
self._add_action(pages_menu, "Pivoter +90", None, lambda: self._rotate_current(90))
self._add_action(pages_menu, "Pivoter -90", None, lambda: self._rotate_current(-90))
@@ -236,6 +239,13 @@ class MainWindow(QMainWindow):
self._add_action(pages_menu, "Inserer des pages blanches", None, self._do_insert_blank)
self._add_action(pages_menu, "Creer un document test...", None, self._do_create_sample)
# Affichage
view_menu = menubar.addMenu("Affichage")
self._rulers_action = QAction("Afficher les regles", self)
self._rulers_action.setCheckable(True)
self._rulers_action.toggled.connect(self._toggle_rulers)
view_menu.addAction(self._rulers_action)
# Annotations
annot_menu = menubar.addMenu("Annotations")
self._add_action(annot_menu, "Ajouter du texte...", None, self._do_add_text)
@@ -319,6 +329,7 @@ class MainWindow(QMainWindow):
rtb.addWidget(self._make_section_label("IMPOSITION"))
rtb.addAction("Livret", self._do_booklet)
rtb.addAction("Delivretiser", self._do_unbooklet)
rtb.addAction("Demonter", self._do_disassemble)
rtb.addAction("N-Up", self._do_nup)
rtb.addAction("Repetition", self._do_step_repeat)
rtb.addAction("Manuel", self._do_manual_impose)
@@ -328,7 +339,7 @@ class MainWindow(QMainWindow):
# Section Pages
rtb.addWidget(self._make_section_label("PAGES"))
rtb.addAction("Recadrer", self._do_crop)
rtb.addAction("Trim/Shift", self._do_trim_shift)
rtb.addAction("Decalage", self._do_trim_shift)
rtb.addAction("Pg blanche", self._do_insert_blank)
rtb.addAction("Inverser", self._do_reverse_pages)
rtb.addAction("Fusionner", self._do_merge)
@@ -633,6 +644,22 @@ class MainWindow(QMainWindow):
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
def _do_disassemble(self):
if not self._require_file():
return
self._show_progress()
output = self._make_temp()
try:
disassemble_pages(self._current_file, output)
self._show_result(output)
self._status_label.setText("Demontage termine — pages coupees en 2")
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
def _toggle_rulers(self, checked):
self._viewer.set_rulers_visible(checked)
def _do_trim_shift(self):
if not self._require_file():
return
@@ -768,18 +795,51 @@ class MainWindow(QMainWindow):
def _do_masking(self):
if not self._require_file():
return
dlg = MaskingDialog(self)
if dlg.exec():
from pypdf import PdfReader
from app.engine.pdf_utils import get_page_size
reader = PdfReader(self._current_file)
cur_page = self._viewer.current_page
page = reader.pages[cur_page]
w, h = get_page_size(page)
self._mask_dlg = MaskingDialog(
page_width_pt=w, page_height_pt=h,
total_pages=len(reader.pages),
current_page=cur_page,
viewer=self._viewer,
parent=self
)
def on_mask_accepted():
if not hasattr(self, '_mask_dlg') or not self._mask_dlg:
return
dlg_ref = self._mask_dlg
self._mask_dlg = None
try:
settings = dlg_ref.get_settings()
except Exception:
return
if not settings.get("rectangles"):
return
self._show_progress()
settings = dlg.get_settings()
output = self._make_temp()
try:
apply_masking(self._current_file, output, **settings)
self._show_result(output)
self._status_label.setText(
f"Masquage applique — {len(settings['rectangles'])} zone(s)"
)
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
def on_mask_rejected():
self._mask_dlg = None
self._mask_dlg.accepted.connect(on_mask_accepted)
self._mask_dlg.rejected.connect(on_mask_rejected)
self._mask_dlg.show()
def _do_bleeds(self):
if not self._require_file():
return
@@ -791,6 +851,18 @@ class MainWindow(QMainWindow):
try:
define_bleeds(self._current_file, output, **settings)
self._show_result(output)
# Stocker et afficher l'overlay rouge sur le viewer
from app.engine.pdf_utils import mm_to_pt
self._bleed_settings = {
"top": mm_to_pt(settings["bleed_top"]),
"bottom": mm_to_pt(settings["bleed_bottom"]),
"left": mm_to_pt(settings["bleed_left"]),
"right": mm_to_pt(settings["bleed_right"]),
}
self._viewer.set_bleed_info(self._bleed_settings)
self._status_label.setText(
f"Fonds perdus definis — zone rouge = bleed"
)
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
@@ -804,15 +876,20 @@ class MainWindow(QMainWindow):
reader = PdfReader(self._current_file)
writer = PdfWriter()
for page in reader.pages:
if hasattr(page, 'trimbox'):
page.trimbox = page.mediabox
if hasattr(page, 'bleedbox'):
page.bleedbox = page.mediabox
# Remettre trimbox et bleedbox a la taille de la mediabox
page.trimbox.lower_left = page.mediabox.lower_left
page.trimbox.upper_right = page.mediabox.upper_right
page.bleedbox.lower_left = page.mediabox.lower_left
page.bleedbox.upper_right = page.mediabox.upper_right
writer.add_page(page)
output = self._make_temp()
with open(output, "wb") as f:
writer.write(f)
self._show_result(output)
# Supprimer l'overlay rouge
self._bleed_settings = None
self._viewer.set_bleed_info(None)
self._status_label.setText("Fonds perdus supprimes")
except Exception as e:
self._hide_progress()
QMessageBox.critical(self, "Erreur", str(e))
@@ -976,20 +1053,21 @@ class MainWindow(QMainWindow):
"crop_right": settings["crop_right"],
}
# Appliquer le crop depuis l'original
# Appliquer TOUS les crops cumules depuis l'original
self._show_progress()
source_file = self._original_file or self._current_file
output = self._make_temp()
try:
crop_pages(source_file, output, **settings)
crop_pages(source_file, output, per_page_settings=self._crop_settings)
self._result_file = output
self._current_file = output
self._viewer.load_pdf(output)
self._viewer.go_to_page(cur_page)
self._page_panel.load_thumbnails(self._viewer)
self._hide_progress()
total_cropped = len(self._crop_settings)
self._status_label.setText(
f"Recadrage applique — {len(settings['pages'])} page(s)"
f"Recadrage applique — {total_cropped} page(s) recadree(s) au total"
)
QTimer.singleShot(100, self._viewer.zoom_fit)
except Exception as e:

View File

@@ -1,13 +1,14 @@
"""Widget de visualisation PDF avec pypdfium2 — V2.5 avec selection redimensionnable."""
"""Widget de visualisation PDF avec pypdfium2 — V3 avec selection + regles."""
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QLabel,
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QScrollArea, QSizePolicy, QApplication)
from PyQt6.QtGui import (QImage, QPixmap, QWheelEvent, QPainter, QPen, QColor,
QMouseEvent, QCursor, QBrush)
QMouseEvent, QCursor, QBrush, QFont)
from PyQt6.QtCore import Qt, pyqtSignal, QRect, QPoint, QSize
import pypdfium2 as pdfium
HANDLE_SIZE = 8 # taille des poignees de redimensionnement
RULER_SIZE = 24 # largeur/hauteur des regles en pixels
class SelectableImageLabel(QLabel):
@@ -231,8 +232,105 @@ class SelectableImageLabel(QLabel):
painter.end()
class RulerWidget(QWidget):
"""Regle graduee en mm (horizontale ou verticale)."""
def __init__(self, orientation=Qt.Orientation.Horizontal, parent=None):
super().__init__(parent)
self._orientation = orientation
self._page_size_pt = 0 # taille de la page en points
self._zoom = 1.0
self._offset = 0 # decalage du scroll
self._img_offset = 0 # offset de l'image dans le label
if orientation == Qt.Orientation.Horizontal:
self.setFixedHeight(RULER_SIZE)
else:
self.setFixedWidth(RULER_SIZE)
def update_params(self, page_size_pt, zoom, offset, img_offset):
self._page_size_pt = page_size_pt
self._zoom = zoom
self._offset = offset
self._img_offset = img_offset
self.update()
def paintEvent(self, event):
if self._page_size_pt <= 0:
return
painter = QPainter(self)
painter.fillRect(self.rect(), QColor(245, 245, 245))
pen = QPen(QColor(120, 120, 120), 1)
painter.setPen(pen)
font = QFont("Segoe UI", 7)
painter.setFont(font)
# pixels par mm
px_per_pt = 150 / 72 * self._zoom
px_per_mm = px_per_pt * (72 / 25.4)
page_size_mm = self._page_size_pt * 25.4 / 72
# Position de depart de la page dans le widget
page_start = self._img_offset - self._offset
horiz = self._orientation == Qt.Orientation.Horizontal
# Determiner le pas des graduations
if px_per_mm >= 4:
step_mm = 5
sub_step_mm = 1
elif px_per_mm >= 2:
step_mm = 10
sub_step_mm = 5
else:
step_mm = 20
sub_step_mm = 10
# Dessiner les graduations
mm_val = 0
while mm_val <= page_size_mm:
px = page_start + mm_val * px_per_mm
if horiz:
if px < 0 or px > self.width():
mm_val += sub_step_mm
continue
else:
if px < 0 or px > self.height():
mm_val += sub_step_mm
continue
is_major = (mm_val % step_mm == 0)
tick_len = RULER_SIZE * 0.6 if is_major else RULER_SIZE * 0.3
if horiz:
x = int(px)
painter.drawLine(x, RULER_SIZE - int(tick_len), x, RULER_SIZE)
if is_major and mm_val > 0:
painter.drawText(x + 2, RULER_SIZE - int(tick_len) - 1, str(int(mm_val)))
else:
y = int(px)
painter.drawLine(RULER_SIZE - int(tick_len), y, RULER_SIZE, y)
if is_major and mm_val > 0:
painter.save()
painter.translate(RULER_SIZE - int(tick_len) - 2, y + 2)
painter.rotate(-90)
painter.drawText(0, 0, str(int(mm_val)))
painter.restore()
mm_val += sub_step_mm
# Ligne de bord
if horiz:
painter.drawLine(0, RULER_SIZE - 1, self.width(), RULER_SIZE - 1)
else:
painter.drawLine(RULER_SIZE - 1, 0, RULER_SIZE - 1, self.height())
painter.end()
class PdfViewer(QWidget):
"""Widget d'apercu PDF avec zoom molette, navigation et selection."""
"""Widget d'apercu PDF avec zoom molette, navigation, selection et regles."""
page_changed = pyqtSignal(int, int)
selection_changed = pyqtSignal(float, float, float, float)
@@ -247,6 +345,8 @@ class PdfViewer(QWidget):
self._zoom = 1.0
self._page_width_pt = 0
self._page_height_pt = 0
self._rulers_visible = False
self._bleed_info = None # dict avec top/bottom/left/right en pt
self._setup_ui()
def _setup_ui(self):
@@ -254,6 +354,18 @@ class PdfViewer(QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Regle horizontale
self._h_ruler = RulerWidget(Qt.Orientation.Horizontal)
self._h_ruler.setVisible(False)
# Zone centrale : regle verticale + scroll
center_layout = QHBoxLayout()
center_layout.setContentsMargins(0, 0, 0, 0)
center_layout.setSpacing(0)
self._v_ruler = RulerWidget(Qt.Orientation.Vertical)
self._v_ruler.setVisible(False)
self._scroll = QScrollArea()
self._scroll.setWidgetResizable(True)
self._scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -268,7 +380,41 @@ class PdfViewer(QWidget):
self._scroll.setWidget(self._image_label)
self._scroll.wheelEvent = self._on_wheel
layout.addWidget(self._scroll, 1)
self._scroll.horizontalScrollBar().valueChanged.connect(self._update_rulers)
self._scroll.verticalScrollBar().valueChanged.connect(self._update_rulers)
center_layout.addWidget(self._v_ruler)
center_layout.addWidget(self._scroll, 1)
# Coin superieur gauche (vide) quand regles actives
h_ruler_layout = QHBoxLayout()
h_ruler_layout.setContentsMargins(0, 0, 0, 0)
h_ruler_layout.setSpacing(0)
self._ruler_corner = QWidget()
self._ruler_corner.setFixedSize(RULER_SIZE, RULER_SIZE)
self._ruler_corner.setStyleSheet("background: #f5f5f5;")
self._ruler_corner.setVisible(False)
h_ruler_layout.addWidget(self._ruler_corner)
h_ruler_layout.addWidget(self._h_ruler, 1)
layout.addLayout(h_ruler_layout)
layout.addLayout(center_layout)
def set_rulers_visible(self, visible):
self._rulers_visible = visible
self._h_ruler.setVisible(visible)
self._v_ruler.setVisible(visible)
self._ruler_corner.setVisible(visible)
if visible:
self._update_rulers()
def rulers_visible(self):
return self._rulers_visible
def set_bleed_info(self, bleed_info):
"""Definir les infos de bleed pour l'overlay rouge. None pour desactiver."""
self._bleed_info = bleed_info
self._render_page()
def set_select_mode(self, enabled):
self._image_label.set_select_mode(enabled)
@@ -279,6 +425,25 @@ class PdfViewer(QWidget):
def show_selection(self, left_ratio, top_ratio, right_ratio, bottom_ratio):
self._image_label.set_selection_from_margins(left_ratio, top_ratio, right_ratio, bottom_ratio)
def _update_rulers(self):
if not self._rulers_visible or not self._pdf:
return
# Calculer l'offset de l'image dans le scroll area
pm = self._image_label.pixmap()
if not pm:
return
img_w, img_h = pm.width(), pm.height()
label_w = self._image_label.width()
label_h = self._image_label.height()
img_off_x = max(0, (label_w - img_w) // 2)
img_off_y = max(0, (label_h - img_h) // 2)
scroll_x = self._scroll.horizontalScrollBar().value()
scroll_y = self._scroll.verticalScrollBar().value()
self._h_ruler.update_params(self._page_width_pt, self._zoom, scroll_x, img_off_x)
self._v_ruler.update_params(self._page_height_pt, self._zoom, scroll_y, img_off_y)
def _on_wheel(self, event):
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
delta = event.angleDelta().y()
@@ -324,11 +489,43 @@ class PdfViewer(QWidget):
pil_image = bitmap.to_pil()
data = pil_image.tobytes("raw", "RGBA")
qimage = QImage(data, pil_image.width, pil_image.height, QImage.Format.Format_RGBA8888)
# Overlay bleed si actif
if self._bleed_info:
from PyQt6.QtGui import QPainter as _P
px_per_pt = scale
bt = self._bleed_info.get("top", 0) * px_per_pt
bb = self._bleed_info.get("bottom", 0) * px_per_pt
bl = self._bleed_info.get("left", 0) * px_per_pt
br = self._bleed_info.get("right", 0) * px_per_pt
img_w = qimage.width()
img_h = qimage.height()
painter = _P(qimage)
painter.setBrush(QColor(255, 0, 0, 50))
painter.setPen(QPen(QColor(255, 0, 0, 120), 1, Qt.PenStyle.DashLine))
# Haut
if bt > 0:
painter.drawRect(0, 0, img_w, int(bt))
# Bas
if bb > 0:
painter.drawRect(0, img_h - int(bb), img_w, int(bb))
# Gauche
if bl > 0:
painter.drawRect(0, int(bt), int(bl), img_h - int(bt) - int(bb))
# Droite
if br > 0:
painter.drawRect(img_w - int(br), int(bt), int(br), img_h - int(bt) - int(bb))
painter.end()
pixmap = QPixmap.fromImage(qimage)
self._image_label.setPixmap(pixmap)
self._image_label.adjustSize()
self.page_changed.emit(self._current_page, self._total_pages)
if self._rulers_visible:
self._update_rulers()
def _zoom_fit(self):
if not self._pdf or self._current_page >= self._total_pages:
return

View File

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

View File

@@ -1 +1 @@
3.5.0
3.6.0