diff --git a/copienator/commands/annotating.py b/copienator/commands/annotating.py index 9617c92..7cc966f 100644 --- a/copienator/commands/annotating.py +++ b/copienator/commands/annotating.py @@ -416,6 +416,7 @@ def compose_label_image(base_img, label, result, hmin, # Draw Headers current_y = 0 draw = ImageDraw.Draw(final_img, "RGBA") + callback_items = [] for el in header_elements: if el["type"] == "score" and more_right: @@ -426,9 +427,9 @@ def compose_label_image(base_img, label, result, hmin, if draw_callback: # Hook for checkboxes - draw_callback("header_item", draw, - {"x": 0, "y": current_y, "w": el["img"].width, "h": el["img"].height}, - el) + callback_items.append(("header_item", + {"x": 0, "y": current_y, "w": el["img"].width, "h": el["img"].height}, + el)) current_y += el["img"].height # Paste Base Image @@ -453,9 +454,9 @@ def compose_label_image(base_img, label, result, hmin, draw.rectangle([target_xmin, target_ymin, target_xmax, target_ymax], outline="red", width=3) if draw_callback: - draw_callback("local_rect", draw, - {"box": [target_xmin, target_ymin, target_xmax, target_ymax]}, - {"data": fb, "index": idx}) + callback_items.append(("local_rect", + {"box": [target_xmin, target_ymin, target_xmax, target_ymax]}, + {"data": fb, "index": idx})) # Render Text txt_img = render_fn(fb['text'], width_px=ANNOT_WIDTH, @@ -481,12 +482,19 @@ def compose_label_image(base_img, label, result, hmin, final_img.paste(txt_img, (10, int(paste_y)), mask=txt_img) if draw_callback: - draw_callback("local_text", draw, - {"x": 10, "y": int(paste_y), "w": txt_img.width, "h": txt_img.height}, - {"data": fb, "index": idx}) + callback_items.append(("local_text", + {"x": 10, "y": int(paste_y), "w": txt_img.width, "h": txt_img.height}, + {"data": fb, "index": idx})) last_text_bottom = paste_y + txt_img.height + # Keep checkbox interiors clear of later rectangles and comment images. + # Use the final canvas, which may have grown while placing comments. + if draw_callback: + draw = ImageDraw.Draw(final_img, "RGBA") + for kind, pos, meta in callback_items: + draw_callback(kind, draw, pos, meta) + final_img.info['annotation_layout'] = layout return final_img, header_height diff --git a/copienator_gui/app.py b/copienator_gui/app.py index 92c7764..4199cc2 100644 --- a/copienator_gui/app.py +++ b/copienator_gui/app.py @@ -2129,6 +2129,9 @@ class CopienatorApp(tk.Tk): return if step_id == "fetch_batches": self.after_idle(self._select_batch_integration) + elif (step_id == "correction" + and self.state_store.step(step_id).get("last_run_variant") == "integrate"): + self.after_idle(self._select_after_batch_integration) elif step_id == "manual_resolution": self.after_idle(self._select_refaire_correction) else: @@ -2241,6 +2244,24 @@ class CopienatorApp(tk.Tk): self.tree.selection_set("correction") self.tree.see("correction") + def _select_after_batch_integration(self) -> None: + """Continue integration with conflict resolution or correction cleanup.""" + evaluation = self.state_store.evaluation + if self.runner.running or self.active_step_id or evaluation is None: + return + if has_manual_conflicts(evaluation / "manual_resolutions.txt"): + target = "manual_resolution" + else: + self.state_store.update_step("manual_resolution", status="success") + self.state_store.add_history({ + "step": "manual_resolution", "status": "success", + "manual": False, "reason": "Aucun conflit manuel après l’intégration batch.", + }) + self._populate_tree() + target = "post_correction" + self.tree.selection_set(target) + self.tree.see(target) + def _select_refaire_correction(self) -> None: """Return from manual conflict resolution to the main redo correction.""" if self.runner.running or self.active_step_id or not self.state_store.evaluation: diff --git a/copienator_gui/cut_helper.py b/copienator_gui/cut_helper.py index 3fa867a..b5cb493 100644 --- a/copienator_gui/cut_helper.py +++ b/copienator_gui/cut_helper.py @@ -80,9 +80,9 @@ class CutHelper(tk.Toplevel): 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="1 — début", variable=self.keep, value=1).pack(side="left", padx=(8, 12)) + ttk.Radiobutton(controls, text="2 — fin", variable=self.keep, value=2).pack(side="left", padx=(0, 24)) + ttk.Radiobutton(controls, text="Ajouter à la cible", variable=self.mode, value=">").pack(side="left", padx=(0, 12)) 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) diff --git a/copienator_gui/giving_names.py b/copienator_gui/giving_names.py index c328879..920f978 100644 --- a/copienator_gui/giving_names.py +++ b/copienator_gui/giving_names.py @@ -193,7 +193,7 @@ class GivingNamesPanel(ttk.LabelFrame): else "A Rendre n’a pas encore été généré." ) ttk.Label(self.issue_frame, text=text).grid( - row=0, column=0, columnspan=4, sticky="w" + row=0, column=0, columnspan=5, sticky="w" ) return @@ -203,6 +203,9 @@ class GivingNamesPanel(ttk.LabelFrame): ttk.Label(self.issue_frame, text="Nouveau nom").grid( row=0, column=1, sticky="w" ) + ttk.Label(self.issue_frame, text="Nom attribué").grid( + row=0, column=4, sticky="w", padx=(12, 0) + ) for row, issue in enumerate(issues, start=1): ttk.Label( self.issue_frame, @@ -226,3 +229,6 @@ class GivingNamesPanel(ttk.LabelFrame): item, value ), ).grid(row=row, column=3, pady=2) + ttk.Label(self.issue_frame, text=issue.name).grid( + row=row, column=4, sticky="w", padx=(12, 0), pady=2 + ) diff --git a/docs/guide_utilisateur_gui.md b/docs/guide_utilisateur_gui.md index e33a92a..652e9ab 100644 --- a/docs/guide_utilisateur_gui.md +++ b/docs/guide_utilisateur_gui.md @@ -279,7 +279,9 @@ passage à la suite. Exécutez lorsque tous les résultats sont disponibles. Vérifiez l’absence de récupération manquante ; l’interface revient ensuite à **Lancer ou intégrer la correction**, en mode **Intégrer les résultats -batch**. Exécutez cette intégration. +batch**. Exécutez cette intégration. Après succès, l’interface passe à +**Résoudre les conflits manuels** s’il en reste, ou directement à +**Nettoyer la correction** sinon. ### Résoudre les conflits manuels — si des conflits sont signalés diff --git a/tests/test_annotation_layout.py b/tests/test_annotation_layout.py index 4668c7a..fa47d90 100644 --- a/tests/test_annotation_layout.py +++ b/tests/test_annotation_layout.py @@ -12,6 +12,7 @@ from copienator.annotation_layout import ( move_answer_pdf, save_parts, ) from copienator.commands import annotating, annotating_by_label, splitting_int +from copienator.commands.annotating_with_checks import CheckboxRenderer from copienator.commands import reading_grouped_annotations as reader from copienator.left_margin import analyse_margins from copienator.pdf_cut import split_pdf @@ -113,6 +114,26 @@ class AnnotationLayoutTests(unittest.TestCase): self.assertEqual(low.getpixel((400, height + 80)), (0, 0, 255)) self.assertEqual(low.getpixel((399, height + 80)), (255, 255, 255)) + def test_later_rectangle_cannot_draw_inside_comment_checkbox(self): + renderer = CheckboxRenderer('Ex 1') + result = {'score': 2, 'feedback': [ + {'text': 'First comment', 'box_2d': [10, 50, 50, 200]}, + {'text': 'Later rectangle', 'box_2d': [10, 300, 80, 400]}, + ]} + with patch.object(annotating, 'render_score_text', return_value=Image.new('RGB', (800, 80), 'white')): + image, _ = annotating.compose_label_image( + Image.new('RGB', (1654, 100), 'white'), 'Ex 1', result, 0, + render_fn=lambda *args, **kwargs: Image.new('RGBA', (620, 50), (255, 200, 200, 180)), + draw_callback=renderer.callback, layout=legacy_layout()) + # The later rectangle's left edge crosses the first comment's checkbox. + checkbox = next(item for item in renderer.checkboxes + if item['type'] == 'del_local' and item['index'] == 0) + x1, y1, x2, y2 = checkbox['final_box'] + interior = image.crop((x1 + 5, y1 + 5, x2 - 5, y2 - 5)) + self.assertEqual(interior.getextrema(), ((255, 255),) * 3) + # The rectangle still exists outside the checkbox. + self.assertEqual(image.getpixel((650, 90)), (255, 0, 0)) + def test_mixed_bgnot_widths_and_saved_geometry_survive_config_change(self): normal, height, _ = self.render(legacy_layout()) low, _, _ = self.render({**legacy_layout(), 'margin_left': 400}) diff --git a/tests/test_gui_convenience.py b/tests/test_gui_convenience.py index f8020f4..5d678ab 100644 --- a/tests/test_gui_convenience.py +++ b/tests/test_gui_convenience.py @@ -372,6 +372,50 @@ class GuiConvenienceTests(unittest.TestCase): self.assertEqual(self.app.state_store.step("correction")["variant"], "integrate") self.assertIn("--deal-with-batched", self.app._make_command()) + def test_batch_integration_success_selects_conflicts_or_cleanup(self): + for conflicts, visited in product((None, "### Aucun conflit\n", "Copie01 A -> B\n"), (False, True)): + with self.subTest(conflicts=conflicts, visited=visited): + manual = self.evaluation / "manual_resolutions.txt" + if conflicts is None: + manual.unlink(missing_ok=True) + else: + manual.write_text(conflicts, encoding="utf-8") + (self.evaluation / "correction.json").write_text("{}", encoding="utf-8") + self.app.state_store.update_step( + "correction", variant="integrate", last_run_variant="integrate" + ) + self.app.state_store.update_step("manual_resolution", visited=visited, status="stale") + self.app._populate_tree() + self.app.tree.selection_set("correction") + self.app.update() + self.app.active_step_id = "correction" + with patch.object(self.app.runner, "start") as start: + self.app._finish_process(0, False) + self.app.update() + start.assert_not_called() + expected = "manual_resolution" if conflicts == "Copie01 A -> B\n" else "post_correction" + self.assertEqual(self.app.current_step.id, expected) + expected_status = "stale" if expected == "manual_resolution" else "success" + self.assertEqual(self.app.state_store.step("manual_resolution")["status"], expected_status) + if expected_status == "success": + self.assertEqual(self.app.tree.item("manual_resolution", "values"), ("Réussie",)) + for step_id in ("submit_batches", "batch_status", "fetch_batches", "prepare_recorrection"): + self.assertFalse(self.app.state_store.step(step_id).get("visited")) + + def test_unsuccessful_batch_integration_stays_on_correction(self): + self.app.state_store.update_step( + "correction", variant="integrate", last_run_variant="integrate" + ) + self.app._populate_tree() + self.app.tree.selection_set("correction") + self.app.update() + for code, interrupted in ((4, False), (1, False), (0, True)): + with self.subTest(code=code, interrupted=interrupted): + self.app.active_step_id = "correction" + self.app._finish_process(code, interrupted) + self.app.update() + self.assertEqual(self.app.current_step.id, "correction") + def test_correction_invalidates_batches_only_when_regenerating_or_resetting(self): for variant, action in product( ("integrate", "live", "refaire", "batch", "hybrid", "reset"), ("run", "mark")