Bundle the trained heatmap/image orientation model and run it after provisional blank-page and split analysis. Confirm candidates with Gemini using low thinking; resolve mixed A3 sheet ordering with the complete statement and medium thinking. Keep A4 scans independent and export paired A3 scans in booklet reading order. Reanalyse uniformly inverted source sheets, validate ordering permutations, and preserve source provenance, corrected rotations and reading slots in manual review. Document the experiments, rejected approaches, calibration and runtime limits. Validation: focused splitting/orientation regression suites pass; bundled model checksum and wheel assets verified. Full Interro07 offline run: 44.26 seconds, eight candidates, mocked upright confirmations, source hashes and geometry intact.
825 lines
35 KiB
Python
825 lines
35 KiB
Python
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 import configuration
|
|
from copienator import (
|
|
CliError,
|
|
EvaluationWorkspace,
|
|
ExitCode,
|
|
execute,
|
|
target_parser,
|
|
workspace_from_target,
|
|
)
|
|
from copienator.platform import launch_pdf_arranger
|
|
from copienator.copy_errors import marked_copy_paths, clear_copy_error
|
|
from copienator.orientation import save_provenance, report_path, provenance_path
|
|
|
|
# --- 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, workspace: EvaluationWorkspace | None = None,
|
|
) -> list[Path]:
|
|
workspace = workspace or EvaluationWorkspace(Path(directory))
|
|
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()
|
|
and not workspace.is_statement_pdf(workspace.root / path.name)]
|
|
|
|
|
|
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 | None,
|
|
) -> 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)
|
|
if generated_path is not None:
|
|
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)
|
|
self.original_page_ids = {page.xref: index + 1 for index, page in enumerate(self.doc)}
|
|
from copienator.commands.automatic_page_splitter import load_decisions
|
|
saved = load_decisions(self.workspace, self.pdf_path)
|
|
self.automatic_plans = {r['source_page']: r for r in saved} if saved else {}
|
|
self.automatic_review = bool(self.automatic_plans)
|
|
self._user_reordered = False
|
|
for number, plan in self.automatic_plans.items():
|
|
page = self.doc[number-1]
|
|
page.set_rotation((page.rotation + plan.get('applied_rotation', 0)) % 360)
|
|
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 = configuration.PAGE_SPLITTER_KB.get(action, "")
|
|
return k
|
|
|
|
# Dynamic instructions text
|
|
instructions = (
|
|
f"{fmt('move_left')} / {fmt('move_right')} : Move active line 0.5cm 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('add_secondary_splitter')}: add secondary line, {fmt('delete_secondary_splitter')}: delete newest (arrows move newest; main line red, active secondary orange)\n"
|
|
f"'{fmt('reverse_pages')}': reverse page order and restart from the new first page\n"
|
|
)
|
|
if self.automatic_review:
|
|
instructions += (
|
|
"\nPropositions automatiques : la touche suivante valide le choix affiché. "
|
|
"Croix rouge : page retirée.\n"
|
|
"Déplacer une ligne reprend le découpage manuel pour cette page."
|
|
)
|
|
|
|
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 = {
|
|
"add_secondary_splitter": self.add_secondary_splitter,
|
|
"delete_secondary_splitter": self.delete_secondary_splitter,
|
|
"move_left": self.move_line_left,
|
|
"move_right": self.move_line_right,
|
|
"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 configuration.PAGE_SPLITTER_KB.items():
|
|
if action in action_map:
|
|
self.master.bind(key, action_map[action])
|
|
|
|
# self.master.bind("<Left>", self.move_line_left)
|
|
# self.master.bind("<Right>", self.move_line_right)
|
|
# 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("<Configure>", 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
|
|
self.secondary_splitters = []
|
|
origin = getattr(self, 'original_page_ids', {}).get(page.xref, self.current_page_index+1)
|
|
plan = getattr(self, 'automatic_plans', {}).get(origin)
|
|
self.current_automatic_outputs = plan['outputs'] if plan else None
|
|
|
|
def _automatic_outputs(self):
|
|
"""Express saved asymmetric crops in the current preview orientation."""
|
|
outputs = getattr(self, 'current_automatic_outputs', None)
|
|
if outputs is None:
|
|
return None
|
|
flipped = (self.current_rotation+self.file_rotation+self.global_rotation) % 360
|
|
if not flipped:
|
|
return outputs
|
|
width, height = self.doc[self.current_page_index].rect.width, self.doc[self.current_page_index].rect.height
|
|
result = []
|
|
for output in outputs:
|
|
x0,y0,x1,y1 = output['crop_points']
|
|
result.append(dict(output, side={'left':'right','right':'left'}.get(output['side'], 'whole'),
|
|
crop_points=[width-x1,height-y1,width-x0,height-y0]))
|
|
return result
|
|
|
|
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"{self.pdf_path.name} — 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)
|
|
# A mixed sheet may have one corrected logical half, not a turned source face.
|
|
original_image = img.copy()
|
|
for output in getattr(self, 'current_automatic_outputs', None) or []:
|
|
if output.get('rotation'):
|
|
box = tuple(round(v*self.current_zoom) for v in output['crop_points'])
|
|
img.paste(original_image.crop(box).rotate(180), box[:2])
|
|
|
|
# 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
|
|
proposed = self._automatic_outputs()
|
|
if proposed is not None:
|
|
for output in proposed:
|
|
x0,y0,x1,y1 = output['crop_points']
|
|
box = tuple(v*self.current_zoom for v in (x0,y0,x1,y1))
|
|
draw.rectangle(box, outline='green' if output['side']=='left' else 'purple', width=3)
|
|
if not output['keep']:
|
|
draw.line((box[0],box[1],box[2],box[3]), fill='red', width=3)
|
|
draw.line((box[0],box[3],box[2],box[1]), fill='red', width=3)
|
|
self.page_label.config(text=self.page_label.cget('text')+' — Proposition automatique (croix rouge : page retirée)')
|
|
else:
|
|
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)
|
|
for index, position in enumerate(self.secondary_splitters):
|
|
x = position * self.current_zoom
|
|
active = index == len(self.secondary_splitters) - 1
|
|
draw.line([(x, 0), (x, pix.height)],
|
|
fill="orange" if active else "cyan", width=3)
|
|
draw.text((x + 5, 8), str(index + 1), fill="orange" if active else "cyan")
|
|
|
|
|
|
# --- 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)
|
|
self.original_page_ids = {page.xref: index + 1 for index, page in enumerate(self.doc)}
|
|
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._user_reordered = True
|
|
self.current_page_index = 0
|
|
self.page_settings = []
|
|
self._initialize_current_page_settings()
|
|
self.load_page()
|
|
|
|
def add_secondary_splitter(self, event=None):
|
|
"""Start a new line in the widest remaining interval of this page."""
|
|
width = self.doc.load_page(self.current_page_index).rect.width
|
|
self.current_automatic_outputs = None
|
|
boundaries = sorted({0, width, self.current_line_x, *self.secondary_splitters})
|
|
left, right = max(zip(boundaries, boundaries[1:]), key=lambda pair: pair[1] - pair[0])
|
|
self.secondary_splitters.append((left + right) / 2)
|
|
self.load_page()
|
|
|
|
def delete_secondary_splitter(self, event=None):
|
|
if self.secondary_splitters:
|
|
self.secondary_splitters.pop()
|
|
self.load_page()
|
|
|
|
def _move_active_line(self, delta):
|
|
self.current_automatic_outputs = None
|
|
width = self.doc.load_page(self.current_page_index).rect.width
|
|
if self.secondary_splitters:
|
|
self.secondary_splitters[-1] = min(width, max(0, self.secondary_splitters[-1] + delta))
|
|
else:
|
|
self.current_line_x = min(width, max(0, self.current_line_x + delta))
|
|
self.load_page()
|
|
|
|
def move_line_left(self, event=None):
|
|
self._move_active_line(-CM_TO_POINTS / 2)
|
|
|
|
def move_line_right(self, event=None):
|
|
self._move_active_line(CM_TO_POINTS / 2)
|
|
|
|
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)
|
|
self._user_reordered = True
|
|
|
|
# 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=None):
|
|
"""Saves the settings for the current page and moves to the next."""
|
|
proposed = self._automatic_outputs()
|
|
if keep is None:
|
|
sides = {o['side'] for o in proposed if o['keep']} if proposed is not None else {'left','right'}
|
|
keep = ('as_is' if 'whole' in sides else 'both' if len(sides)==2
|
|
else next(iter(sides)) if sides else 'none')
|
|
self.page_settings.append({
|
|
"line_x": self.current_line_x,
|
|
"secondary_splitters": list(self.secondary_splitters),
|
|
"rotation": self.current_rotation,
|
|
"keep": keep,
|
|
"automatic_outputs": proposed,
|
|
})
|
|
|
|
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:
|
|
if all(s['keep']=='none' for s in self.page_settings):
|
|
self.doc.close()
|
|
output = commit_processed_pdf(self.workspace, self.pdf_path, None)
|
|
provenance_path(self.workspace, output.stem).unlink(missing_ok=True)
|
|
else:
|
|
self.split_pdf()
|
|
self.reorder_pdfs()
|
|
self.concate_files()
|
|
output = commit_processed_pdf(self.workspace, self.pdf_path, self.final_file)
|
|
save_provenance(self.workspace, output, self.output_origins)
|
|
clear_copy_error(self.workspace, output, source='orientation')
|
|
clear_copy_error(self.workspace, output, source='page_splitter')
|
|
report_path(self.workspace, output.stem).unlink(missing_ok=True)
|
|
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)
|
|
|
|
self.fragment_origins = {}
|
|
self.fragment_order = {}
|
|
for i, settings in enumerate(self.page_settings):
|
|
page = self.doc.load_page(i)
|
|
origin = getattr(self, 'original_page_ids', {}).get(page.xref, i + 1)
|
|
line_x = settings['line_x']
|
|
rotation_settings = settings['rotation']
|
|
keep = settings['keep']
|
|
# Normalize the displayed page without losing an offset CropBox.
|
|
# This matches the automatic export's coordinate system.
|
|
source_rotation = page.rotation
|
|
width, height = page.rect.width, page.rect.height
|
|
normalized = pymupdf.open()
|
|
flat = normalized.new_page(width=width, height=height)
|
|
page.set_rotation(0)
|
|
if page.get_contents():
|
|
flat.show_pdf_page(flat.rect, self.doc, i, rotate=-source_rotation)
|
|
flat.set_rotation((rotation_settings+self.file_rotation+self.global_rotation) % 360)
|
|
flat.remove_rotation()
|
|
sides = []
|
|
side_metadata = {}
|
|
if keep == "as_is":
|
|
filename = self.split_filename_left(i)
|
|
sides.append((0, width, filename))
|
|
proposed = settings.get('automatic_outputs') or []
|
|
whole = next((o for o in proposed if o['side']=='whole'), None)
|
|
if whole is not None:
|
|
side_metadata[str(filename)] = whole
|
|
elif settings.get('automatic_outputs') is not None:
|
|
for output in settings['automatic_outputs']:
|
|
side = output['side']
|
|
if keep == 'both' or keep == side:
|
|
x0,_,x1,_ = output['crop_points']
|
|
filename = self.split_filename_left(i) if side=='left' else self.split_filename_right(i)
|
|
sides.append((x0,x1,filename))
|
|
side_metadata[str(filename)] = output
|
|
else:
|
|
if keep in {"both", "left"} and line_x > 0:
|
|
sides.append((0, line_x, self.split_filename_left(i)))
|
|
if keep in {"both", "right"} and line_x < width:
|
|
sides.append((line_x, width, self.split_filename_right(i)))
|
|
|
|
for left, right, output_path in sides:
|
|
metadata = side_metadata.get(str(output_path), {})
|
|
cuts = settings.get("secondary_splitters", [])
|
|
boundaries = [left, *sorted({x for x in cuts if left < x < right}), right]
|
|
# Each side is a multi-page PDF, keeping its subpages together
|
|
# when the existing booklet ordering moves this side.
|
|
origins = []
|
|
with pymupdf.open() as output:
|
|
for x0, x1 in zip(boundaries, boundaries[1:]):
|
|
# Cuts are placed on the displayed (corrected) logical page.
|
|
# Turning a half also reverses its secondary strips.
|
|
clip = (pymupdf.Rect(left+right-x1, 0, left+right-x0, height)
|
|
if metadata.get('rotation') else pymupdf.Rect(x0, 0, x1, height))
|
|
target = output.new_page(width=clip.width, height=clip.height)
|
|
if flat.get_contents():
|
|
target.show_pdf_page(target.rect, normalized, 0, clip=clip)
|
|
if metadata.get('rotation'):
|
|
target.set_rotation(metadata['rotation'])
|
|
target.remove_rotation()
|
|
origins.append({'origin': origin, 'crop_points': list(clip)})
|
|
output.save(output_path)
|
|
self.fragment_origins[str(output_path)] = origins
|
|
self.fragment_order[str(output_path)] = metadata.get('reading_order',
|
|
metadata.get('output_page', 100000+i*2+(left>0)))
|
|
normalized.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
|
|
|
|
def copy_side(source, destination):
|
|
if os.path.isfile(source):
|
|
shutil.copy2(source, destination)
|
|
if hasattr(self, 'fragment_origins'):
|
|
self.fragment_origins[str(destination)] = self.fragment_origins[str(source)]
|
|
|
|
if (getattr(self, 'automatic_review', False) and getattr(self, 'fragment_order', None)
|
|
and not getattr(self, '_user_reordered', False)):
|
|
# Saved output numbers include booklet/Gemini order; do not reorder twice.
|
|
for ri, source in enumerate(sorted(self.fragment_order, key=self.fragment_order.get)):
|
|
copy_side(source, self.reorder_filename(ri))
|
|
return
|
|
|
|
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 not getattr(self, 'automatic_review', False) and 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"]:
|
|
copy_side(self.split_filename_right(i), self.reorder_filename(ri))
|
|
ri += 1
|
|
|
|
# 2. Intérieur Gauche
|
|
if ps[i+1]['keep'] in ["both", "left"]:
|
|
copy_side(self.split_filename_left(i+1), self.reorder_filename(ri))
|
|
ri += 1
|
|
|
|
# 3. Intérieur Droit
|
|
if ps[i+1]['keep'] in ["both", "right"]:
|
|
copy_side(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"]:
|
|
copy_side(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"]:
|
|
copy_side(self.split_filename_left(i), self.reorder_filename(ri))
|
|
ri += 1
|
|
if psk in ["right", "both"]:
|
|
copy_side(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"):
|
|
# copy_side(self.split_filename_right(i), self.reorder_filename(ri))
|
|
# ri += 1
|
|
# if ps[i+1]['keep'] != "none":
|
|
# copy_side(self.split_filename_left(i+1), self.reorder_filename(ri))
|
|
# ri += 1
|
|
# if ps[i+1]['keep'] != "left":
|
|
# copy_side(self.split_filename_right(i+1), self.reorder_filename(ri))
|
|
# ri += 1
|
|
# if ps[i]['keep'] == "both":
|
|
# copy_side(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":
|
|
# copy_side(self.split_filename_left(i), self.reorder_filename(ri))
|
|
# ri += 1
|
|
# if psk == "right" or psk == "both":
|
|
# copy_side(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
|
|
)
|
|
|
|
self.output_origins = []
|
|
for pdf in pdf_files:
|
|
self.output_origins.extend(getattr(self, 'fragment_origins', {}).get(str(pdf), []))
|
|
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)
|
|
if workspace.is_statement_pdf(workspace.root / target.name):
|
|
raise CliError(f"Target is the statement PDF, not a copy: {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, workspace)
|
|
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, workspace)
|
|
if target == workspace.root and not candidates:
|
|
candidates = list_pdf_files(workspace.original_copies_dir, workspace)
|
|
return list(reversed(candidates))
|
|
|
|
|
|
def run(workspace: EvaluationWorkspace, target: Path, *, marked: bool = False,
|
|
automatic: bool = False, workers: int = 4) -> ExitCode:
|
|
inputs = list(reversed(marked_copy_paths(workspace, originals=True))) if marked else _selected_inputs(workspace, target)
|
|
if automatic:
|
|
from copienator.commands.automatic_page_splitter import run as run_automatic
|
|
return run_automatic(workspace, list(reversed(inputs)), workers=workers)
|
|
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:
|
|
parser = target_parser("Interactively split and reorder scanned PDF pages")
|
|
parser.add_argument("--marked", action="store_true", help="Process only copies flagged during margin review")
|
|
parser.add_argument("--automatic", action="store_true", help="Split automatically and save editable proposals without reordering")
|
|
parser.add_argument("--workers", type=int, default=4, help="Copies analysed simultaneously in automatic mode")
|
|
return parser
|
|
|
|
|
|
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, marked=args.marked, automatic=args.automatic, workers=args.workers)
|
|
|
|
return execute(parser, argv, handle)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|