From 060859ddef7c92094e8a1392ed3a1a6450ce5275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Miquel?= Date: Thu, 10 Sep 2026 20:03:23 +0200 Subject: [PATCH] cropping summaries --- copienator/commands/crop_exercise_bottoms.py | 35 ++++++++++++++++++-- copienator/commands/crop_margins.py | 27 ++++++++++++++- tests/test_crop_exercise_bottoms_command.py | 33 ++++++++++++++++++ tests/test_crop_margins_command.py | 29 ++++++++++++++++ 4 files changed, 120 insertions(+), 4 deletions(-) diff --git a/copienator/commands/crop_exercise_bottoms.py b/copienator/commands/crop_exercise_bottoms.py index b12ab18..15ea5c3 100644 --- a/copienator/commands/crop_exercise_bottoms.py +++ b/copienator/commands/crop_exercise_bottoms.py @@ -134,6 +134,27 @@ def _publish( return backup_root +def crop_statistics(records: list[dict]) -> tuple[int, float]: + """Return cropped exercise count and mean removed percentage per exercise.""" + totals: dict[str, list[float]] = {} + cropped_files: set[str] = set() + for record in records: + total_height, removed_height = totals.setdefault(record["file"], [0.0, 0.0]) + totals[record["file"]] = [ + total_height + float(record["height_lines"]), + removed_height + float(record["bottom_removed_lines"]), + ] + if record["status"] == "cropped": + cropped_files.add(record["file"]) + percentages = [ + min(100.0, totals[file_name][1] / totals[file_name][0] * 100) + for file_name in cropped_files + if totals[file_name][0] > 0 + ] + mean_percentage = sum(percentages) / len(percentages) if percentages else 0.0 + return len(cropped_files), mean_percentage + + def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> ExitCode: if workers < 1: raise CliError( @@ -161,15 +182,23 @@ def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> Ex raise CliError( f"{source} a changé pendant l’analyse. Aucun PDF remplacé." ) + cropped_exercises, mean_percentage = crop_statistics(records) if not changed_files: - print("Terminé : aucun PDF ne remplit les critères de rognage.", flush=True) + print("Terminé : aucun exercice ne remplit les critères de rognage.", flush=True) + print( + "Rognage moyen des exercices modifiés : 0.0 %.", flush=True + ) return ExitCode.SUCCESS backup = _publish(workspace, changed_files, staging, records) cropped_pages = sum(record["status"] == "cropped" for record in records) print(f"Sauvegarde des PDF non rognés : {backup}", flush=True) print( - f"Terminé : {cropped_pages} page(s) rognée(s) dans " - f"{len(changed_files)} PDF remplacé(s).", + f"Terminé : {cropped_exercises} exercice(s) rogné(s), soit " + f"{cropped_pages} page(s) dans {len(changed_files)} PDF remplacé(s).", + flush=True, + ) + print( + f"Rognage moyen des exercices modifiés : {mean_percentage:.1f} %.", flush=True, ) return ExitCode.SUCCESS diff --git a/copienator/commands/crop_margins.py b/copienator/commands/crop_margins.py index 646a444..fe480de 100644 --- a/copienator/commands/crop_margins.py +++ b/copienator/commands/crop_margins.py @@ -83,6 +83,26 @@ def process_copies(files: list[Path], staging: Path, workers: int) -> list[dict] 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.", @@ -109,9 +129,14 @@ def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> Ex (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 = sum(row["top_removed_mm"]+row["bottom_removed_mm"] > 0 for row in records) + 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 diff --git a/tests/test_crop_exercise_bottoms_command.py b/tests/test_crop_exercise_bottoms_command.py index ec94b02..4bce875 100644 --- a/tests/test_crop_exercise_bottoms_command.py +++ b/tests/test_crop_exercise_bottoms_command.py @@ -49,6 +49,8 @@ class CropExerciseBottomsCommandTests(unittest.TestCase): 0, ) self.assertIn("1 PDF remplacé", log.getvalue()) + self.assertIn("1 exercice(s) rogné(s)", log.getvalue()) + self.assertIn("Rognage moyen des exercices modifiés", log.getvalue()) with pymupdf.open(self.large) as cropped: self.assertLess(cropped[0].rect.height, 250) self.assertEqual(self.short.read_bytes(), self.short_original) @@ -68,6 +70,37 @@ class CropExerciseBottomsCommandTests(unittest.TestCase): ) self.assertTrue((backups[0].parents[2] / "report.json").is_file()) + def test_crop_statistics_average_percentages_by_exercise(self): + records = [ + { + "file": "Copies/Copie01/Ex 1.pdf", + "height_lines": 10.0, + "bottom_removed_lines": 4.0, + "status": "cropped", + }, + { + "file": "Copies/Copie01/Ex 1.pdf", + "height_lines": 10.0, + "bottom_removed_lines": 0.0, + "status": "skipped-short", + }, + { + "file": "Copies/Copie01/Ex 2.pdf", + "height_lines": 10.0, + "bottom_removed_lines": 5.0, + "status": "cropped", + }, + { + "file": "Copies/Copie01/Ex 3.pdf", + "height_lines": 10.0, + "bottom_removed_lines": 0.0, + "status": "unchanged-small-crop", + }, + ] + count, average = crop_exercise_bottoms.crop_statistics(records) + self.assertEqual(count, 2) + self.assertAlmostEqual(average, 35.0) + def test_detection_failure_does_not_publish_an_earlier_result(self): broken = self.answers / "Ex 3.pdf" broken.write_bytes(b"not a PDF") diff --git a/tests/test_crop_margins_command.py b/tests/test_crop_margins_command.py index 0bd424f..cb3f6b4 100644 --- a/tests/test_crop_margins_command.py +++ b/tests/test_crop_margins_command.py @@ -37,6 +37,9 @@ class CropMarginsCommandTests(unittest.TestCase): with contextlib.redirect_stdout(io.StringIO()) as log: self.assertEqual(main(["crop-margins", str(self.workspace.root)]), 0) self.assertIn("Page 2/2", log.getvalue()) + self.assertIn("1/2 pages rognées", log.getvalue()) + self.assertIn("Rognage moyen des pages modifiées", log.getvalue()) + self.assertIn("page(s) rognée(s) de plus de 30 %", log.getvalue()) with pymupdf.open(self.source) as result: self.assertEqual(len(result), 2) self.assertLess(result[0].rect.height, 150) @@ -50,6 +53,32 @@ class CropMarginsCommandTests(unittest.TestCase): self.assertEqual(backups[0].read_bytes(), self.original) self.assertTrue((backups[0].parent.parent/"report.json").is_file()) + def test_crop_statistics_use_only_modified_pages(self): + records = [ + { + "top_removed_mm": 10.0, + "bottom_removed_mm": 20.0, + "original_cropbox": [0, 0, 200, 300], + "rotation": 0, + }, + { + "top_removed_mm": 40.0, + "bottom_removed_mm": 0.0, + "original_cropbox": [0, 0, 200, 300], + "rotation": 90, + }, + { + "top_removed_mm": 0.0, + "bottom_removed_mm": 0.0, + "original_cropbox": [0, 0, 200, 300], + "rotation": 0, + }, + ] + count, average, over_thirty = crop_margins.crop_statistics(records) + self.assertEqual(count, 2) + self.assertAlmostEqual(average, (28.3465 + 56.6929) / 2, places=3) + self.assertEqual(over_thirty, 1) + def test_failure_or_interruption_never_publishes_partial_batch(self): second = self.workspace.copies_dir/"Copie02.pdf" second.write_bytes(self.original)