from __future__ import annotations import tkinter as tk from pathlib import Path from tkinter import messagebox, ttk import pymupdf from PIL import Image, ImageTk from copienator.pdf_cut import cut_position PAGE_GAP = 28 SNAP_PIXELS = 12 def percentage_at_y(y: float, heights: list[float], scale: float) -> float: """Convert canvas y to document height, snapping across inter-page gaps.""" top = 0.0 cumulative = 0.0 total = sum(heights) for index, height in enumerate(heights): bottom = top + height * scale if index + 1 < len(heights) and bottom - SNAP_PIXELS <= y <= bottom + PAGE_GAP + SNAP_PIXELS: return (cumulative + height) / total * 100 if y <= bottom: return max(0.0, min(100.0, (cumulative + (y - top) / scale) / total * 100)) cumulative += height top = bottom + PAGE_GAP return 100.0 def y_at_percentage(percent: float, heights: list[float], scale: float) -> float: if percent <= 0: return 0.0 if percent >= 100: return sum(heights) * scale + (len(heights) - 1) * PAGE_GAP index, offset = cut_position(heights, percent) top = sum(heights[:index]) * scale + index * PAGE_GAP return top - PAGE_GAP / 2 if index and offset == 0 else top + offset * scale def cut_operator(percent: float, keep: int, mode: str) -> str: value = f"{percent:.6f}".rstrip("0").rstrip(".") return f"c{{{value}}}{keep}{mode}" class CutHelper(tk.Toplevel): def __init__(self, parent, path: Path, on_accept, initial=None): # Load the source before creating a window so invalid PDFs leave no dialog. with pymupdf.open(path) as document: if not len(document): raise ValueError("Le PDF est vide.") self.heights = [page.rect.height for page in document] width = min(850, parent.winfo_screenwidth() - 100) self.scale = min(1.5, width / max(page.rect.width for page in document)) rendered = [] for page in document: pix = page.get_pixmap(matrix=pymupdf.Matrix(self.scale, self.scale), alpha=False, colorspace=pymupdf.csRGB) rendered.append(Image.frombytes("RGB", (pix.width, pix.height), pix.samples)) super().__init__(parent) self.title(f"Cut — {path.name}") self.geometry(f"{width + 45}x{min(850, parent.winfo_screenheight() - 100)}") self.transient(parent.winfo_toplevel()) self.on_accept = on_accept self.percent = initial[0] if initial else 50.0 self.keep = tk.IntVar(value=initial[1] if initial else 1) self.mode = tk.StringVar(value=initial[2] if initial else ">") self.caption = tk.StringVar() ttk.Label(self, text="Déplacez la barre rouge. Entrée : afficher la commande ; Échap : annuler.", wraplength=width).pack(anchor="w", padx=8, pady=5) controls = ttk.Frame(self) controls.pack(fill="x", padx=8) ttk.Label(controls, text="Conserver à la source :").pack(side="left") ttk.Radiobutton(controls, text="1 — début", variable=self.keep, value=1).pack(side="left") ttk.Radiobutton(controls, text="2 — fin", variable=self.keep, value=2).pack(side="left") ttk.Radiobutton(controls, text="Ajouter à la cible", variable=self.mode, value=">").pack(side="left") ttk.Radiobutton(controls, text="Remplacer", variable=self.mode, value="x").pack(side="left") ttk.Label(self, textvariable=self.caption).pack(anchor="w", padx=8, pady=5) viewport = ttk.Frame(self) viewport.pack(fill="both", expand=True) self.canvas = tk.Canvas(viewport, background="#555555", highlightthickness=0) scrollbar = ttk.Scrollbar(viewport, command=self.canvas.yview) self.canvas.configure(yscrollcommand=scrollbar.set) scrollbar.pack(side="right", fill="y") self.canvas.pack(fill="both", expand=True) self.images = [ImageTk.PhotoImage(image, master=self) for image in rendered] top = 0.0 self.width = width for index, image in enumerate(self.images): self.canvas.create_image(0, top, anchor="nw", image=image) top += self.heights[index] * self.scale if index + 1 < len(self.images): top += PAGE_GAP self.canvas.configure(scrollregion=(0, 0, width, top)) self.bar = self.canvas.create_line(0, 0, width, 0, fill="#ff3030", width=4) self.canvas.bind("", self.move_bar) self.canvas.bind("", self.move_bar) self.canvas.bind("", lambda event: self.canvas.yview_scroll(-3, "units")) self.canvas.bind("", lambda event: self.canvas.yview_scroll(3, "units")) self.canvas.bind("", lambda event: self.canvas.yview_scroll(-1 if event.delta > 0 else 1, "units")) self.bind("", self.accept) self.bind("", lambda event: self.destroy()) self.keep.trace_add("write", lambda *_: self.draw_bar()) self.mode.trace_add("write", lambda *_: self.draw_bar()) self.draw_bar() self.canvas.yview_moveto(max(0, (y_at_percentage(self.percent, self.heights, self.scale) - 200) / top)) self.focus_set() self.grab_set() def move_bar(self, event): self.percent = percentage_at_y(self.canvas.canvasy(event.y), self.heights, self.scale) self.draw_bar() def draw_bar(self): y = y_at_percentage(self.percent, self.heights, self.scale) self.canvas.coords(self.bar, 0, y, self.width, y) text = cut_operator(self.percent, self.keep.get(), self.mode.get()) if 0 < self.percent < 100: index, offset = cut_position(self.heights, self.percent) text += f" — entre les pages {index} et {index + 1}" if offset == 0 else f" — page {index + 1}" self.caption.set(text) def accept(self, event=None): operator = cut_operator(self.percent, self.keep.get(), self.mode.get()) rounded = float(operator.split("{")[1].split("}")[0]) if not 0 < rounded < 100: messagebox.showerror("Coupe invalide", "Chaque partie doit contenir une portion du PDF.", parent=self) return self.destroy() self.on_accept(operator)