This commit is contained in:
sebastien committed 2026-10-23 08:24:10 +02:00
1 parent c30aac0572
commit 834ffe32f2
7 files changed
+113 -11

No files matched your search

+14 -6
View File
@@ -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,
callback_items.append(("header_item",
{"x": 0, "y": current_y, "w": el["img"].width, "h": el["img"].height},
el)
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,
callback_items.append(("local_rect",
{"box": [target_xmin, target_ymin, target_xmax, target_ymax]},
{"data": fb, "index": idx})
{"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,
callback_items.append(("local_text",
{"x": 10, "y": int(paste_y), "w": txt_img.width, "h": txt_img.height},
{"data": fb, "index": idx})
{"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
+21
View File
@@ -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:
+3 -3
View File
@@ -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)
+7 -1
View File
@@ -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
)
+3 -1
View File
@@ -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
+21
View File
@@ -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})
+44
View File
@@ -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")