Files
Copies/copienator_gui/cut_helper.py
T

132 lines
6.2 KiB
Python

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("<Button-1>", self.move_bar)
self.canvas.bind("<B1-Motion>", self.move_bar)
self.canvas.bind("<Button-4>", lambda event: self.canvas.yview_scroll(-3, "units"))
self.canvas.bind("<Button-5>", lambda event: self.canvas.yview_scroll(3, "units"))
self.canvas.bind("<MouseWheel>", lambda event: self.canvas.yview_scroll(-1 if event.delta > 0 else 1, "units"))
self.bind("<Return>", self.accept)
self.bind("<Escape>", 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)