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:
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
|
||||
Reference in New Issue
Block a user