"""Optional preprocessing: replace split copies with ink-guided crops.""" from __future__ import annotations import argparse import hashlib import json import multiprocessing import signal import shutil import tempfile import cv2 from collections.abc import Sequence from pathlib import Path from copienator.cli import CliError, ExitCode, execute, target_parser, workspace_from_target from copienator.crop_blank_margins import process_pdf from copienator.filesystem import staged_files from copienator.workspace import EvaluationWorkspace def selected_files(workspace: EvaluationWorkspace, target: Path) -> list[Path]: workspace.require_directories("Copies") copies = workspace.copies_dir.resolve() target = target.resolve() if target.is_file(): if target.parent != copies or target.suffix.lower() != ".pdf": raise CliError("La cible doit être un PDF du dossier Copies.", ExitCode.INVALID_ARGUMENTS) files = [target] elif target in (workspace.root, copies): files = sorted(copies.glob("*.pdf"), key=lambda path: path.name.casefold()) else: raise CliError("Cible attendue : évaluation, dossier Copies ou PDF dans Copies.", ExitCode.INVALID_ARGUMENTS) for source in files: if source.is_symlink(): raise CliError(f"Lien symbolique non pris en charge : {source}") if source.with_suffix(".json").exists(): raise CliError( f"{source.name} possède déjà des coordonnées de labels. " "Le rognage doit précéder leur détection. Pour reprendre le prétraitement, " "mettez de côté le JSON associé, puis régénérez la découpe des marges et les labels. " "Aucun PDF n’a été remplacé.") return files def _initialize_worker() -> None: # Five copies should use five cores, not five OpenCV thread pools. MuPDF # must also stay isolated in separate processes rather than Python threads. cv2.setNumThreads(1) signal.signal(signal.SIGINT, signal.SIG_IGN) def _process_copy(job: tuple[Path, Path]) -> list[dict]: source, destination = job def progress(page, total, row): removed = row["top_removed_mm"]+row["bottom_removed_mm"] print(f"{source.name} — Page {page}/{total} : {removed:.1f} mm retirés", flush=True) return process_pdf(source, destination, None, 200, 6, 5, progress=progress) def process_copies(files: list[Path], staging: Path, workers: int) -> list[dict]: jobs = [(source, staging/source.name) for source in files] count = min(workers, len(jobs)) print(f"Rognage de {len(files)} copies avec {count} traitement(s) en parallèle.", flush=True) if count == 1: previous_threads = cv2.getNumThreads() cv2.setNumThreads(1) try: batches = [_process_copy(job) for job in jobs] finally: cv2.setNumThreads(previous_threads) else: # spawn works on Windows and avoids inheriting GUI/native-library state. # Pool's context terminates and joins workers on errors or cancellation # before staged_files removes the unpublished PDFs. with multiprocessing.get_context("spawn").Pool(count, _initialize_worker) as pool: batches = list(pool.imap_unordered(_process_copy, jobs)) order = {source.name: i for i, source in enumerate(files)} return sorted((row for batch in batches for row in batch), key=lambda row: (order[row["file"]], row["page"])) def crop_statistics(records: list[dict]) -> tuple[int, float, int]: """Return cropped page count, their mean removed percentage, and >30% count.""" percentages: list[float] = [] for record in records: removed_mm = record["top_removed_mm"] + record["bottom_removed_mm"] if removed_mm <= 0: continue x0, y0, x1, y1 = record["original_cropbox"] original_height_points = ( x1 - x0 if record.get("rotation", 0) % 180 else y1 - y0 ) if original_height_points <= 0: continue original_height_mm = original_height_points * 25.4 / 72 percentages.append(min(100.0, removed_mm / original_height_mm * 100)) mean_percentage = sum(percentages) / len(percentages) if percentages else 0.0 over_thirty = sum(percentage > 30 for percentage in percentages) return len(percentages), mean_percentage, over_thirty def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> ExitCode: if workers < 1: raise CliError("Le nombre de traitements parallèles doit être positif.", ExitCode.INVALID_ARGUMENTS) files = selected_files(workspace, target) if not files: raise CliError("Aucun PDF trouvé dans Copies.", ExitCode.INVALID_ARGUMENTS) # Prepare the whole batch before replacing any copy. A detection failure or # interruption leaves the working PDFs intact; commit errors roll back. with staged_files(workspace.copies_dir) as staging: records = process_copies(files, staging, workers) # Detect edits made while the batch was being analysed, before saving # backups or publishing results derived from an obsolete source. digests = {row["file"]: row["source_sha256"] for row in records} for source in files: if hashlib.sha256(source.read_bytes()).hexdigest() != digests[source.name]: raise CliError(f"{source.name} a changé pendant l’analyse. Aucun PDF remplacé.") workspace.runs_dir.mkdir(parents=True, exist_ok=True) backup = Path(tempfile.mkdtemp(prefix="crop-margins-", dir=workspace.runs_dir)) originals = backup/"Copies" originals.mkdir() for source in files: shutil.copy2(source, originals/source.name) (backup/"report.json").write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8") print(f"Sauvegarde des PDF non rognés : {originals}", flush=True) cropped, mean_percentage, over_thirty = crop_statistics(records) print(f"Terminé : {cropped}/{len(records)} pages rognées ; " f"{len(files)} PDF remplacés dans Copies.", flush=True) print( f"Rognage moyen des pages modifiées : {mean_percentage:.1f} % ; " f"{over_thirty} page(s) rognée(s) de plus de 30 %.", flush=True, ) return ExitCode.SUCCESS def main(argv: Sequence[str] | None = None) -> int: parser = target_parser("Rogner les zones vides des PDF dans Copies, avant les labels") parser.add_argument("--workers", type=int, default=5, help="Nombre de copies traitées en parallèle (défaut : 5)") def handle(args: argparse.Namespace) -> ExitCode: workspace, target = workspace_from_target(args) return run(workspace, target, workers=args.workers) return execute(parser, argv, handle) if __name__ == "__main__": raise SystemExit(main())