from __future__ import annotations import argparse import glob import os import re import shutil import tempfile import tkinter as tk import uuid from collections.abc import Sequence from pathlib import Path from tkinter import messagebox import pymupdf # PyMuPDF from PIL import Image, ImageDraw, ImageTk from pypdf import PdfReader, PdfWriter from copienator.configuration import PAGE_SPLITTER_KB from copienator import ( CliError, EvaluationWorkspace, ExitCode, execute, target_parser, workspace_from_target, ) from copienator.platform import launch_pdf_arranger # Keep the new shortcut available with older personal configuration files. PAGE_SPLITTER_KB = {"reverse_pages": "i", **PAGE_SPLITTER_KB} # --- Constants --- # Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch) CM_TO_POINTS = (1 / 2.54) * 72 def list_pdf_files(directory: str | Path) -> list[Path]: paths = sorted(Path(directory).glob("*.pdf"), key=lambda path: path.name.casefold()) return [path for path in paths if "enonce" not in path.name.casefold()] def _temporary_sibling(path: Path, purpose: str) -> Path: return path.with_name(f".{path.name}.{purpose}.{uuid.uuid4().hex}.tmp") def commit_processed_pdf( workspace: EvaluationWorkspace, original_path: Path, generated_path: Path, ) -> Path: """Commit a processed copy and its original backup with rollback.""" backup_path = workspace.original_copies_dir / original_path.name output_path = workspace.copies_dir / original_path.name workspace.original_copies_dir.mkdir(parents=True, exist_ok=True) workspace.copies_dir.mkdir(parents=True, exist_ok=True) staged_backup = None if original_path.resolve() != backup_path.resolve(): staged_backup = _temporary_sibling(backup_path, "new-original") shutil.copy2(original_path, staged_backup) saved_backup = _temporary_sibling(backup_path, "old-original") saved_output = _temporary_sibling(output_path, "old-output") backup_replaced = False output_replaced = False try: if staged_backup is not None: if backup_path.exists(): backup_path.replace(saved_backup) staged_backup.replace(backup_path) backup_replaced = True if output_path.exists(): output_path.replace(saved_output) generated_path.replace(output_path) output_replaced = True if original_path.resolve() not in { backup_path.resolve(), output_path.resolve(), }: original_path.unlink() except Exception: if output_replaced and output_path.exists(): output_path.unlink() if saved_output.exists(): saved_output.replace(output_path) if backup_replaced and backup_path.exists(): backup_path.unlink() if saved_backup.exists(): saved_backup.replace(backup_path) raise finally: for temporary in (staged_backup, saved_backup, saved_output): if temporary is not None and temporary.exists(): temporary.unlink() return output_path class PDFPreviewer: def setup_next_file(self): self.num += 1 if len(self.inputs) == 0: return False self.pdf_path = self.inputs.pop() self.file_rotation = 0 self.base_name = self.pdf_path.stem self._temporary_directory = tempfile.TemporaryDirectory( prefix=f".{self.base_name}.page-splitter.", dir=self.workspace.root, ) working_dir = Path(self._temporary_directory.name) self.split_dir = working_dir / "split" self.reorder_dir = working_dir / "reorder" self.final_file = working_dir / f"{self.base_name}.pdf" self.current_page_index = 0 self.page_settings = [] self.processing = False # Flag to prevent multiple finish calls try: self.doc = pymupdf.open(self.pdf_path) except (OSError, RuntimeError, ValueError) as e: self.failed = True self._temporary_directory.cleanup() messagebox.showerror("Error", f"Failed to open PDF file: {e}") self.master.destroy() return self.master.title(f"PDF Splitter - {self.pdf_path.name}") return True def __init__( self, master: tk.Tk, workspace: EvaluationWorkspace, inputs: list[Path], ) -> None: """ Initializes the application. Args: master (tk.Tk): The root Tkinter window. pdf_path (str): The path to the input PDF file. """ self.workspace = workspace self.inputs = inputs self.output_dir = None self.master = master self.num = 0 self.global_rotation = 0 # Rotation appliquée à tous les fichiers self.history = [] self.failed = False if not self.setup_next_file(): print(f"No PDF files found in {workspace.root}") master.destroy() return self._resize_job = None # For debouncing resize events self._initialize_current_page_settings() # --- UI Setup --- # Set a reasonable initial size for the window self.master.geometry("800x1000") def fmt(action): k = PAGE_SPLITTER_KB.get(action, "") return k # Dynamic instructions text instructions = ( f"{fmt('move_left')} / {fmt('move_right')} : Move line 1cm left/right\n" f"'{fmt('rotate_page')}': Rotate page 180°, '{fmt('rotate_all_pages')}' : rotate all pages, '{fmt('rotate_all_files')}' : rotate all files\n" f"{fmt('keep_left')} {fmt('next_page')} {fmt('discard_page')} {fmt('keep_right')} {fmt('keep_as_is')}: keep left, next page, keep none, keep right, keep as is\n" f"{fmt('send_end')}: send page to end, '{fmt('arranger')}': pdf arranger, '{fmt('restart_file')}': restart file, '{fmt('prev_file')}': previous file\n" f"'{fmt('reverse_pages')}': reverse page order and restart from the new first page\n" ) self.info_label = tk.Label(master, text=instructions, justify=tk.LEFT) self.info_label.pack(pady=5, side=tk.TOP) self.page_label = tk.Label(master, text="", font=("Helvetica", 12)) self.page_label.pack(pady=5, side=tk.TOP) # Canvas for PDF page preview self.canvas = tk.Canvas(master, bg="gray") self.canvas.pack(fill="both", expand=True) # --- Bindings --- action_map = { "move_left": self.move_line_left, "move_right": self.move_line_right, "confirm_next": self.confirm_and_next_page, "rotate_page": self.rotate_page, "rotate_all_pages": self.rotate_all_pages, "rotate_all_files": self.rotate_all_files, "keep_left": self.keep_left, "keep_right": self.keep_right, "keep_as_is": self.keep_as_is, "next_page": self.confirm_and_next_page, "discard_page": self.discard_page, "send_end": self.send_page_end, "reverse_pages": self.reverse_pages, "restart_file": self.restart_current_file, "arranger": self.start_arranger, "prev_file": self.go_to_previous_file, } for action, key in PAGE_SPLITTER_KB.items(): if action in action_map: self.master.bind(key, action_map[action]) # self.master.bind("", self.move_line_left) # self.master.bind("", self.move_line_right) # self.master.bind("", self.confirm_and_next_page) # self.master.bind("c", self.rotate_page) # self.master.bind("C", self.rotate_all_pages) # self.master.bind(",", self.rotate_all_files) # self.master.bind("t", self.keep_left) # self.master.bind("n", self.keep_right) # self.master.bind("m", self.keep_as_is) # self.master.bind("s", self.confirm_and_next_page) # self.master.bind("r", self.discard_page) # self.master.bind("z", self.send_page_end) # self.master.bind("R", self.restart_current_file) # self.master.bind("A", self.start_arranger) # self.master.bind("P", self.go_to_previous_file) # Bind the resize event on the canvas self.canvas.bind("", self.on_resize) self.current_zoom = 1.0 def start_arranger(self): try: launch_pdf_arranger(self.pdf_path) except FileNotFoundError as exc: messagebox.showerror("PDF Arranger", str(exc)) def on_resize(self, event): """ Handles window resize events by reloading the page. Uses a "debounce" mechanism to avoid excessive redrawing. """ if self._resize_job: self.master.after_cancel(self._resize_job) self._resize_job = self.master.after(250, self.load_page) # Redraw after 250ms of no resizing def _initialize_current_page_settings(self): """Initializes or resets the settings for the current page.""" if self.current_page_index < len(self.doc): page = self.doc.load_page(self.current_page_index) self.current_line_x = page.rect.width / 2 self.current_rotation = 0 def load_page(self): """Loads and displays the current page on the canvas, scaled to fit.""" if self.current_page_index >= len(self.doc): if not self.processing: self.processing = True self.finish_and_process() return page = self.doc.load_page(self.current_page_index) self.page_label.config(text=f"Page {self.current_page_index + 1} of {len(self.doc)}") # --- Calculate Scaling --- canvas_width = self.canvas.winfo_width() canvas_height = self.canvas.winfo_height() # Don't try to render if the canvas has no size yet. if canvas_width <= 1 or canvas_height <= 1: return page_rect = page.rect zoom_x = canvas_width / page_rect.width zoom_y = canvas_height / page_rect.height # Use 98% of the smallest zoom factor to leave a small margin self.current_zoom = min(zoom_x, zoom_y) * 0.98 # --- Render Page --- mat = pymupdf.Matrix(self.current_zoom, self.current_zoom) pix = page.get_pixmap(matrix=mat, alpha=False) img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) # Apply rotation if needed *after* drawing the line if (self.current_rotation + self.file_rotation + self.global_rotation) % 360 != 0: img = img.rotate(180, expand=True) # --- Draw Line and Rotate --- draw = ImageDraw.Draw(img) # The line position is scaled by the same zoom factor line_x_scaled = self.current_line_x * self.current_zoom draw.line([(line_x_scaled, 0), (line_x_scaled, pix.height)], fill="red", width=3) # --- Display on Canvas --- self.photo_img = ImageTk.PhotoImage(img) self.canvas.delete("all") # Center the image on the canvas self.canvas.create_image(canvas_width / 2, canvas_height / 2, anchor="center", image=self.photo_img) def restart_current_file(self, event=None): """Restarts the processing of the current file.""" # Close the modified in-memory document if hasattr(self, 'doc'): self.doc.close() # Re-open the file from disk to reset changes (like moved pages) try: self.doc = pymupdf.open(self.pdf_path) except (OSError, RuntimeError, ValueError) as e: messagebox.showerror("Error", f"Failed to reopen PDF file: {e}") self.master.destroy() return # Reset state variables for the current file self.file_rotation = 0 self.current_page_index = 0 self.page_settings = [] self.processing = False # Reload UI self._initialize_current_page_settings() self.load_page() def reverse_pages(self, event=None): """Reverse the current document and discard earlier page decisions.""" if self.processing or not len(self.doc): return self.doc.select(list(reversed(range(len(self.doc))))) self.current_page_index = 0 self.page_settings = [] self._initialize_current_page_settings() self.load_page() def move_line_left(self, event=None): """Moves the split line to the left.""" self.current_line_x = max(0, self.current_line_x - CM_TO_POINTS / 2) self.load_page() def move_line_right(self, event=None): """Moves the split line to the right.""" page = self.doc.load_page(self.current_page_index) self.current_line_x = min(page.rect.width, self.current_line_x + CM_TO_POINTS / 2) self.load_page() def rotate_page(self, event=None): """Toggles the page rotation between 0 and 180 degrees.""" self.current_rotation = 180 if self.current_rotation == 0 else 0 self.load_page() def rotate_all_pages(self, event=None): """Toggles the page rotation between 0 and 180 degrees.""" self.file_rotation = 180 if self.file_rotation == 0 else 0 self.load_page() def rotate_all_files(self, event=None): """Toggles the page rotation between 0 and 180 degrees.""" self.global_rotation = 180 if self.global_rotation == 0 else 0 self.load_page() def keep_left(self, event=None): self.confirm_and_next_page(keep="left") def keep_right(self, event=None): self.confirm_and_next_page(keep="right") def discard_page(self, event=None): self.confirm_and_next_page(keep="none") def keep_as_is(self, event=None): self.confirm_and_next_page(keep="as_is") def send_page_end(self, event=None): # Do nothing if we are already at or past the last page if self.current_page_index >= len(self.doc) - 1: return # Move the current page to the end of the document # -1 as the destination puts it after the last page self.doc.move_page(self.current_page_index, -1) # Initialize settings for the page that shifted into the current slot self._initialize_current_page_settings() # Reload the canvas to show the new page self.load_page() def confirm_and_next_page(self, event=None, keep="both"): """Saves the settings for the current page and moves to the next.""" self.page_settings.append({ "line_x": self.current_line_x, "rotation": self.current_rotation, "keep": keep }) self.current_page_index += 1 if self.current_page_index < len(self.doc): self._initialize_current_page_settings() self.load_page() else: if not self.finish_and_process(): return self.history.append(self.pdf_path) if self.setup_next_file(): self._initialize_current_page_settings() self.load_page() else: self.master.destroy() def finish_and_process(self) -> bool: """Render and transactionally install the processed PDF.""" try: self.split_pdf() self.reorder_pdfs() self.concate_files() commit_processed_pdf(self.workspace, self.pdf_path, self.final_file) except Exception as exc: # noqa: BLE001 - interactive boundary self.failed = True self.processing = False print(f"Failed to process {self.pdf_path}: {exc}") messagebox.showerror("Error", f"Failed to process PDF: {exc}") self._temporary_directory.cleanup() self.master.destroy() return False else: self._temporary_directory.cleanup() return True def go_to_previous_file(self, event=None): """Goes back to the beginning of the previously completed file.""" if not self.history: return # Nowhere to go back to # Close the currently open document to avoid lock issues if hasattr(self, 'doc'): self.doc.close() if hasattr(self, "_temporary_directory"): self._temporary_directory.cleanup() # 1. Push current file back onto the stack so it processes next self.inputs.append(self.pdf_path) # 2. Reprocess the previous file from its preserved original backup prev_file = self.history.pop() backup = self.workspace.original_copies_dir / Path(prev_file).name self.inputs.append(backup if backup.is_file() else Path(prev_file)) # 3. Reload environment (setup_next_file will pop prev_file back off the stack) self.setup_next_file() self._initialize_current_page_settings() self.load_page() def split_filename_left(self, i): return os.path.join(self.split_dir, f"{self.base_name}_{i+1}l.pdf") def split_filename_right(self, i): return os.path.join(self.split_dir, f"{self.base_name}_{i+1}r.pdf") def reorder_filename(self, i): return os.path.join(self.reorder_dir, f"{self.base_name}_{i+1}.pdf") def clean_up_dir(self, dir, make=True): if make: os.makedirs(dir, exist_ok=True) pdf_files = glob.glob(os.path.join(dir, "*.pdf")) for pdf in pdf_files: try: os.remove(pdf) except OSError as e: print(f"Error deleting {pdf}: {e}") def split_pdf(self): """Splits each page of the PDF according to the saved settings.""" print("Starting PDF processing...") self.clean_up_dir(self.split_dir) for i, settings in enumerate(self.page_settings): page = self.doc.load_page(i) line_x = settings['line_x'] rotation_settings = settings['rotation'] keep = settings['keep'] rotation = (page.rotation + rotation_settings + self.file_rotation + self.global_rotation) % 360 if keep == "as_is": doc_full = pymupdf.open() page_full = doc_full.new_page(width=page.rect.width, height=page.rect.height) page_full.show_pdf_page(page_full.rect, self.doc, i) page_full.set_rotation(rotation) output_path_full = self.split_filename_left(i) doc_full.save(output_path_full) doc_full.close() continue # Skip left/right generation # --- Create Left Part --- if rotation == 0: rect_left = pymupdf.Rect(0, 0, line_x, page.rect.height) else: rect_left = pymupdf.Rect(page.rect.width-line_x, 0, page.rect.width, page.rect.height) if (keep == "both" or keep == "left") and line_x > 0: doc_left = pymupdf.open() page_left = doc_left.new_page(width=rect_left.width, height=rect_left.height) page_left.show_pdf_page(page_left.rect, self.doc, i, clip=rect_left) page_left.set_rotation(rotation) output_path_left = self.split_filename_left(i) doc_left.save(output_path_left) doc_left.close() # --- Create Right Part --- if rotation == 0: rect_right = pymupdf.Rect(line_x, 0, page.rect.width, page.rect.height) else: rect_right = pymupdf.Rect(0, 0, page.rect.width-line_x, page.rect.height) if (keep == "both" or keep == "right") and line_x < page.rect.width: doc_right = pymupdf.open() page_right = doc_right.new_page(width=rect_right.width, height=rect_right.height) page_right.show_pdf_page(page_right.rect, self.doc, i, clip=rect_right) page_right.set_rotation(rotation) output_path_right = self.split_filename_right(i) doc_right.save(output_path_right) doc_right.close() self.doc.close() print(f"\nProcessing complete. Files are in '{self.split_dir}' directory.") def reorder_pdfs(self): """Reordonne les pages, si ce sont des copies doubles.""" self.clean_up_dir(self.reorder_dir) ps = self.page_settings ri = 0 i = 0 while i < len(ps): psk = ps[i]['keep'] # Si c'est une copie double (on s'assure qu'on a bien 2 pages consécutives modifiables) if psk in ["both", "right", "left", "none"] and i < len(ps)-1 and ps[i+1]['keep'] in ["both", "right", "left", "none"]: # 1. Page de garde (Extérieur Droit) if ps[i]['keep'] in ["both", "right"]: shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri)) ri += 1 # 2. Intérieur Gauche if ps[i+1]['keep'] in ["both", "left"]: shutil.copy2(self.split_filename_left(i+1), self.reorder_filename(ri)) ri += 1 # 3. Intérieur Droit if ps[i+1]['keep'] in ["both", "right"]: shutil.copy2(self.split_filename_right(i+1), self.reorder_filename(ri)) ri += 1 # 4. Dos de la copie (Extérieur Gauche) if ps[i]['keep'] in ["both", "left"]: shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri)) ri += 1 i += 2 else: # Si c'est une page simple (ou as_is) if psk in ["left", "both", "as_is"]: shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri)) ri += 1 if psk in ["right", "both"]: shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri)) ri += 1 i += 1 # def reorder_pdfs(self): # """Reordonne les pages, si ce sont des copies doubles.""" # self.clean_up_dir(self.reorder_dir) # ps = self.page_settings # ri = 0 # i = 0 # while i < len(ps): # # Si c'est une copie double # if (ps[i]['keep'] == "both" or ps[i]['keep'] == "right") \ # and i < len(ps)-1 and (ps[i+1]['keep'] != "right"): # shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri)) # ri += 1 # if ps[i+1]['keep'] != "none": # shutil.copy2(self.split_filename_left(i+1), self.reorder_filename(ri)) # ri += 1 # if ps[i+1]['keep'] != "left": # shutil.copy2(self.split_filename_right(i+1), self.reorder_filename(ri)) # ri += 1 # if ps[i]['keep'] == "both": # shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri)) # ri += 1 # i += 2 # else: # psk = ps[i]['keep'] # if psk == "left" or psk == "both" or psk == "as_is": # shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri)) # ri += 1 # if psk == "right" or psk == "both": # shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri)) # ri += 1 # i += 1 def concate_files(self): writer = PdfWriter() def natural_key(text): return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', text)] pdf_files = sorted( glob.glob(os.path.join(self.reorder_dir, "*.pdf")), key=natural_key ) for pdf in pdf_files: reader = PdfReader(pdf) for page in reader.pages: writer.add_page(page) if self.output_dir != None: os.makedirs(os.path.dirname(self.final_file), exist_ok=True) with open(self.final_file, "wb") as f: writer.write(f) print(f"Created merged PDF: {self.final_file}") def _selected_inputs( workspace: EvaluationWorkspace, target: Path, ) -> list[Path]: if target.is_file(): if target.suffix.casefold() != ".pdf": raise CliError(f"Target is not a PDF: {target}", ExitCode.INVALID_ARGUMENTS) backup = workspace.original_copies_dir / target.name return [backup if backup.is_file() else target] directory = target if target == workspace.copies_dir: candidates = list_pdf_files(workspace.copies_dir) candidates = [ ( workspace.original_copies_dir / path.name if (workspace.original_copies_dir / path.name).is_file() else path ) for path in candidates ] else: candidates = list_pdf_files(directory) return list(reversed(candidates)) def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode: inputs = _selected_inputs(workspace, target) if not inputs: print(f"No PDF files found in {target}") return ExitCode.SUCCESS root = tk.Tk() application = PDFPreviewer(root, workspace, inputs) root.mainloop() return ExitCode.FAILURE if application.failed else ExitCode.SUCCESS def build_parser() -> argparse.ArgumentParser: return target_parser("Interactively split and reorder scanned PDF pages") def main(argv: Sequence[str] | None = None) -> int: parser = build_parser() def handle(args: argparse.Namespace) -> ExitCode: workspace, target = workspace_from_target(args) return run(workspace, target) return execute(parser, argv, handle) if __name__ == "__main__": raise SystemExit(main())