diff --git a/copienator/commands/annotating.py b/copienator/commands/annotating.py index 0f311fa..8d26182 100644 --- a/copienator/commands/annotating.py +++ b/copienator/commands/annotating.py @@ -4,6 +4,7 @@ import argparse import io import os import re +import shutil import subprocess import tempfile from collections.abc import Sequence @@ -175,6 +176,10 @@ def render_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=N final_img.alpha_composite(img) return final_img +class AnnotationLatexError(RuntimeError): + """A feedback comment failed to render; diagnostics have been retained.""" + + def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=None, fontsize=19): dpi = 100 width_in = width_px / dpi @@ -194,16 +199,37 @@ def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_li f.write(latex_template) # Compile to PDF - subprocess.run( + compilation = subprocess.run( ['pdflatex', '-interaction=nonstopmode', 'text.tex'], cwd=temp_dir, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, check=False, ) if not os.path.exists(pdf_path): - raise RuntimeError("LaTeX compilation failed. Check your LaTeX syntax.") + diagnostics = Path(tempfile.mkdtemp(prefix="copienator-latex-error-")) + shutil.copy2(tex_path, diagnostics / "text.tex") + log_path = Path(temp_dir) / "text.log" + output = (compilation.stdout or b"").decode("utf-8", errors="replace") + (diagnostics / "compiler-output.txt").write_text(output, encoding="utf-8") + if log_path.exists(): + shutil.copy2(log_path, diagnostics / "text.log") + log = log_path.read_text(encoding="utf-8", errors="replace") + else: + log = output + lines = log.splitlines() + first_error = next((i for i, line in enumerate(lines) if line.startswith("!")), max(0, len(lines) - 20)) + excerpt = "\n".join(lines[first_error:first_error + 25]) + raise AnnotationLatexError( + f"Échec de compilation LaTeX (code {compilation.returncode}).\n" + f"Commentaire LaTeX fautif :\n{text}\n\n" + f"Extrait du journal :\n{excerpt}\n\n" + f"Source LaTeX complète : {diagnostics / 'text.tex'}\n" + f"Diagnostics conservés dans : {diagnostics}\n" + "Modifiez le commentaire concerné dans correction.json, enregistrez le fichier, " + "puis relancez cette étape de génération des annotations." + ) # Convert PDF to grayscale (ignoring pdf2image's broken transparency) images = convert_from_path(pdf_path, dpi=dpi) @@ -295,7 +321,7 @@ def compose_label_image(base_img, label, result, hmin, with_error=True, with_empty=False, more_right=False, - with_id=None): + with_id=None, source_pdf=None): """ Composes the final image with annotations. @@ -309,6 +335,21 @@ def compose_label_image(base_img, label, result, hmin, called when elements are placed. Used for checkboxes. """ + original_render = render_fn + + def render_fn(text, *args, **kwargs): + try: + return original_render(text, *args, **kwargs) + except AnnotationLatexError as exc: + context = f"Question : {label}" + if source_pdf is not None: + source = Path(source_pdf) + context = f"Copie : {source.parent.name} — {context}\nPDF source : {source}" + context += f"\nFichier à modifier : {source.parent.parent.parent / 'correction.json'}" + elif with_id is not None: + context = f"Copie : {with_id} — {context}" + raise AnnotationLatexError(f"{context}\n{exc}") from exc + left_pad = 0 if base_img.width < TARGET_MIN_WIDTH: total_missing = TARGET_MIN_WIDTH - base_img.width @@ -489,6 +530,7 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite): coordinates[0], with_empty=True, render_fn=render_real_latex_text, + source_pdf=pdf_full_path, ) final_img.save(staging / f"{label}.jpg") if result.get('error', "") != "empty-answer": diff --git a/copienator/commands/annotating_by_label.py b/copienator/commands/annotating_by_label.py index 45e769a..0cb614f 100644 --- a/copienator/commands/annotating_by_label.py +++ b/copienator/commands/annotating_by_label.py @@ -47,6 +47,7 @@ def render_item(item): draw_callback=checkbox_renderer.callback, more_right=True, with_id=student_id, + source_pdf=pdf_path, ) if final_image is None: return None diff --git a/copienator/commands/annotating_with_checks.py b/copienator/commands/annotating_with_checks.py index badaac0..324c4b7 100644 --- a/copienator/commands/annotating_with_checks.py +++ b/copienator/commands/annotating_with_checks.py @@ -184,6 +184,7 @@ def _render_student( content["result"], content["coordinates"][0], draw_callback=checkbox_renderer.callback, + source_pdf=pdf_path, ) if final_image is None: problems = True diff --git a/copienator/json_io.py b/copienator/json_io.py index e7aefea..598212e 100644 --- a/copienator/json_io.py +++ b/copienator/json_io.py @@ -23,6 +23,14 @@ def read_json(path: str | Path, *, default: T | object = _MISSING) -> T: target = Path(path) try: with target.open("r", encoding="utf-8") as stream: + if target.name == "refaire.json": + # This user-edited selection supports whole-line // comments. + # Preserve line numbers in JSON diagnostics and keep other JSON strict. + text = "".join( + "\n" if line.lstrip().startswith("//") else line + for line in stream + ) + return cast(T, json.loads(text)) return cast(T, json.load(stream)) except FileNotFoundError: if default is _MISSING: diff --git a/copienator_gui/app.py b/copienator_gui/app.py index ff43e82..8b88832 100644 --- a/copienator_gui/app.py +++ b/copienator_gui/app.py @@ -30,6 +30,7 @@ from .scores import ScoreEditorPanel from .notifications import notify_desktop from .syncthing import SyncthingPanel from .manual_resolution import ManualResolutionPanel +from .recorrection import prepare_selection_file from .refaire import SECTION as REFAIRE_SECTION from .refaire import ( RefaireSelection, @@ -930,6 +931,14 @@ class CopienatorApp(tk.Tk): self.arg_vars['target'].trace_add('write', lambda *_args: self._refresh_persp_inventory()) self._refresh_persp_inventory() + if step.id == "prepare_recorrection": + self.edit_recorrection_button = ttk.Button( + self.form, text="Ouvrir refaire.json (avec exemples)", + command=self._edit_recorrection, + ) + self.edit_recorrection_button.grid(row=row, column=0, columnspan=2, sticky="w", pady=8) + row += 1 + if step.id == "manual_resolution": def manual_evaluation(): target = self.arg_vars.get("target") @@ -1117,6 +1126,7 @@ class CopienatorApp(tk.Tk): "cutleft", "labels", "plotting", + "splitting", } if step.id in copy_selection_steps: evaluation = self.evaluation @@ -1310,6 +1320,16 @@ class CopienatorApp(tk.Tk): except (OSError, RuntimeError) as exc: messagebox.showerror("Ouverture impossible", str(exc)) + def _edit_recorrection(self) -> None: + if self.evaluation is None: + return + try: + path = prepare_selection_file(self.evaluation) + except OSError as exc: + messagebox.showerror("Préparation impossible", str(exc)) + return + self._open_desktop_path(path, "fichier refaire.json") + def _select_variant(self, index: int) -> None: if not self.current_step or index < 0: return @@ -1546,6 +1566,15 @@ class CopienatorApp(tk.Tk): if step.id == "refaire_selection": self._save_refaire_selection() return + if step.id == "prepare_recorrection": + try: + load_selection(evaluation) + except (OSError, ValueError, TypeError) as exc: + messagebox.showerror("Sélection à vérifier", str(exc)) + return + self._mark_step("success") + self._select_refaire_correction() + return if step.section == REFAIRE_SECTION: try: self._refaire_selection() diff --git a/copienator_gui/recorrection.py b/copienator_gui/recorrection.py new file mode 100644 index 0000000..1bae1b6 --- /dev/null +++ b/copienator_gui/recorrection.py @@ -0,0 +1,25 @@ +"""Editable examples for the optional correction redo step.""" + +from pathlib import Path + +from copienator import atomic_write_text + + +EXAMPLES = """// Exemples de sélection pour refaire la correction +// Les lignes commençant par // sont des commentaires et sont ignorées. +// Modifiez la liste JSON ci-dessous, puis enregistrez le fichier. +// Une copie entière : [["Copie46", []]] +// Certaines questions : [["Copie46", ["II)8)", "II)9)"]] +// Plusieurs copies : [["Copie46", []], ["Copie03", ["Ex 1"]]] +// Utilisez les noms exacts des copies et des labels de votre évaluation. +// [] après une copie signifie toutes ses questions ; [] seul ne sélectionne rien. +// Séparez les entrées par des virgules, sans virgule après la dernière. +""" + + +def prepare_selection_file(evaluation: Path) -> Path: + path = evaluation / "refaire.json" + existing = path.read_text(encoding="utf-8") if path.exists() else "[]\n" + if not existing.startswith(EXAMPLES): + atomic_write_text(path, EXAMPLES + existing) + return path diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index e1c213b..3d4d62a 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -371,12 +371,12 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "splitting", "Labels et regroupement", "Découper les réponses par question", - "Découpe les copies à partir des coordonnées de labels vérifiées.", + "Découpe les copies à partir des coordonnées de labels vérifiées. " + "Ciblez une copie avec le sélecteur ou tout le dossier, puis cliquez sur Exécuter.", (python("default", "Découpage", "split-answers"),), arguments=(arg_target(),), requires=("Copies",), artifacts=("Copies/Copie*/*",), - auto_start_first_visit=True, ), StepDefinition( "crop_exercise_bottoms", @@ -511,6 +511,19 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: requires=("manual_resolutions.txt", "correction.json"), skip_without_manual_conflicts=True, ), + StepDefinition( + "prepare_recorrection", + "Correction", + "Choisir des copies à recorriger", + "Facultatif : ouvrez refaire.json et indiquez les copies entières ou les questions à recorriger. " + "Les exemples commentés expliquent la syntaxe. Enregistrez le fichier, puis marquez cette étape terminée : " + "vous reviendrez à Correction avec --refaire sélectionné, sans lancement automatique. " + "Les corrections sélectionnées seront remplacées ; celles des autres copies seront conservées. " + "Si vous avez modifié les découpes, régénérez les réponses avant la recorrection. " + "Ignorez cette étape si aucune reprise n’est nécessaire.", + (manual("default"),), + optional=True, + ), StepDefinition( "post_correction", "Correction", diff --git a/docs/guide_utilisateur_gui.md b/docs/guide_utilisateur_gui.md index a1f7067..c667771 100644 --- a/docs/guide_utilisateur_gui.md +++ b/docs/guide_utilisateur_gui.md @@ -251,6 +251,31 @@ coupure, et quelle moitié doit être gardée. L'interface graphique vous fournira alors une instruction texte à copier et à mettre dans le fichier texte. +Vous pouvez aussi ajouter vos propres instructions dans +`manual_resolutions.txt`, même si Gemini n’a pas signalé ces conflits. +Les lignes sont appliquées **de haut en bas**, avec une distinction importante : + +- La **source** est toujours le PDF tel qu’il existait au début de cette + exécution, avant les modifications des lignes précédentes. +- La **cible** est sa version courante, tenant compte des lignes précédentes. + +Par exemple, pour une copie ayant les labels `A`, `B` et `C` : + +```text +Copie46 A c{40}1> B| +Copie46 C -> A| +``` + +La première ligne coupe A à 40 % de sa hauteur cumulée, conserve la première +partie sous le label A et ajoute les 60 % restants après B. La seconde ligne +ajoute C après **la partie conservée de A**, et non après A entier. C reste +également disponible sous son propre label. + +En revanche, si une ligne ultérieure utilise **A comme source**, elle utilise +le PDF A entier du début de l’exécution, pas seulement sa partie conservée. +Une seule coupe par label source et par copie est autorisée dans une même +exécution. Remplacez `A`, `B` et `C` par les labels exacts de votre évaluation. + Enregistrez le fichier texte, éventuellement le **Recharger**, puis **Exécuter**. diff --git a/tests/test_annotation_latex_errors.py b/tests/test_annotation_latex_errors.py new file mode 100644 index 0000000..7daaedb --- /dev/null +++ b/tests/test_annotation_latex_errors.py @@ -0,0 +1,58 @@ +import tempfile +import unittest +from pathlib import Path +from subprocess import CompletedProcess +from unittest.mock import Mock, patch + +from PIL import Image + +from copienator.commands import annotating + + +class AnnotationLatexErrorTests(unittest.TestCase): + def test_failure_preserves_source_and_log_and_explains_recovery(self): + with tempfile.TemporaryDirectory() as directory: + diagnostics = Path(directory) / "diagnostics" + diagnostics.mkdir() + + def compile_latex(command, **kwargs): + Path(kwargs["cwd"], "text.log").write_bytes( + b"preamble\n! Missing \\endcsname inserted.\nl.12 \\end{document}\n\xff" + ) + return CompletedProcess(command, 1, stdout=b"compiler output\xff") + + # Patch only the retained diagnostics directory, not TemporaryDirectory. + original_mkdtemp = tempfile.mkdtemp + + def make_directory(*args, **kwargs): + if kwargs.get("prefix") == "copienator-latex-error-": + return str(diagnostics) + return original_mkdtemp(*args, **kwargs) + + with patch.object(annotating.subprocess, "run", side_effect=compile_latex), patch.object( + annotating.tempfile, "mkdtemp", side_effect=make_directory + ): + with self.assertRaises(annotating.AnnotationLatexError) as caught: + annotating.render_real_latex_text(r"Antislash \.", 400) + message = str(caught.exception) + for expected in ("code 1", r"Antislash \.", "Missing", "correction.json", "relancez cette étape", str(diagnostics)): + self.assertIn(expected, message) + self.assertIn(r"Antislash \.", (diagnostics / "text.tex").read_text()) + self.assertTrue((diagnostics / "text.log").exists()) + self.assertTrue((diagnostics / "compiler-output.txt").exists()) + + def test_comment_error_identifies_copy_label_and_correction_file(self): + for box in (None, [0, 0, 20, 20]): + with self.subTest(box=box), patch.object( + annotating, "render_score_text", return_value=Image.new("RGBA", (200, 40)) + ): + with self.assertRaises(annotating.AnnotationLatexError) as caught: + annotating.compose_label_image( + Image.new("RGB", (800, 200)), "I)7)", + {"feedback": [{"text": "Bad comment", "box_2d": box}]}, 0, + render_fn=Mock(side_effect=annotating.AnnotationLatexError("Bad comment")), + source_pdf=Path("/exam/Copies/Copie39/I)7).pdf"), + ) + message = str(caught.exception) + for expected in ("Copie39", "I)7)", "/exam/correction.json", "Bad comment"): + self.assertIn(expected, message) diff --git a/tests/test_gui_convenience.py b/tests/test_gui_convenience.py index 3d51611..0cb74f6 100644 --- a/tests/test_gui_convenience.py +++ b/tests/test_gui_convenience.py @@ -214,6 +214,49 @@ class GuiConvenienceTests(unittest.TestCase): self.assertNotIn("overwrite", self.app.arg_vars) self.assertIn("--refaire", self.app._make_command()) + def test_optional_recorrection_opens_examples_and_returns_to_correction(self): + from copienator import read_json + ids = [step.id for step in self.app.steps] + self.assertEqual(ids[ids.index("manual_resolution") + 1], "prepare_recorrection") + copies = self.evaluation / "Copies" + copies.mkdir() + (copies / "Copie46.pdf").touch() + (self.evaluation / "labels").write_text("II)9)\n") + self.app.tree.selection_set("prepare_recorrection") + self.app.update() + with patch("copienator_gui.app.open_path") as opened: + self.app.edit_recorrection_button.invoke() + path = self.evaluation / "refaire.json" + opened.assert_called_once_with(path) + self.assertEqual(read_json(path), []) + path.write_text(path.read_text().replace("\n[]\n", '\n[["Copie46", []]]\n')) + with patch.object(self.app.runner, "start") as start: + self.app.run_button.invoke() + self.app.update() + start.assert_not_called() + self.assertEqual(self.app.state_store.step("prepare_recorrection")["status"], "success") + self.assertEqual(self.app.current_step.id, "correction") + self.assertEqual(self.app.variant_var.get(), "refaire") + self.assertIn("--refaire", self.app._make_command()) + self.assertNotIn("--overwrite", self.app._make_command()) + + def test_ignoring_optional_recorrection_continues_without_creating_file(self): + self.app.tree.selection_set("prepare_recorrection") + self.app.update() + self.app._skip_step() + self.app.update() + self.assertEqual(self.app.current_step.id, "post_correction") + self.assertEqual(self.app.state_store.step("prepare_recorrection")["status"], "skipped") + self.assertFalse((self.evaluation / "refaire.json").exists()) + + def test_optional_recorrection_requires_a_saved_selection(self): + self.app.tree.selection_set("prepare_recorrection") + self.app.update() + with patch("copienator_gui.app.messagebox.showerror") as error: + self.app.run_button.invoke() + error.assert_called_once() + self.assertEqual(self.app.current_step.id, "prepare_recorrection") + def test_giving_names_waits_for_explicit_manual_completion(self): return_dir = self.evaluation / "A Rendre" / "Student (01)" return_dir.mkdir(parents=True) @@ -560,6 +603,25 @@ class GuiConvenienceTests(unittest.TestCase): self.assertIn("Cibler la copie sélectionnée", controls) self.assertIn("Cibler tout le dossier", controls) + def test_answer_splitting_waits_for_target_and_can_select_one_copy(self): + copies = self.evaluation / "Copies" + copies.mkdir() + for name in ("Copie01.pdf", "Copie46.pdf"): + (copies / name).touch() + with patch.object(self.app, "_run_current_step") as run: + self.app.tree.selection_set("splitting") + self.app.update() + run.assert_not_called() + self.assertIn("Copie46.pdf", self.app.copy_paths) + self.app.copy_var.set("Copie46.pdf") + self.app._target_selected_copy() + command = self.app._make_command() + self.assertIn("split-answers", command) + self.assertEqual(Path(command[5]), copies / "Copie46.pdf") + self.app._target_all_pages() + command = self.app._make_command() + self.assertEqual((self.repository / command[5]).resolve(), self.evaluation) + def test_label_review_can_show_and_target_one_copy(self): copies = self.evaluation / "Copies" copies.mkdir() diff --git a/tests/test_gui_core.py b/tests/test_gui_core.py index 2b4a6b2..de895b4 100644 --- a/tests/test_gui_core.py +++ b/tests/test_gui_core.py @@ -2378,7 +2378,7 @@ class WorkflowTests(unittest.TestCase): self.assertEqual( auto_start, - {"crop_blank_margins", "splitting", "crop_exercise_bottoms", "grouping"}, + {"crop_blank_margins", "crop_exercise_bottoms", "grouping"}, ) self.assertEqual( skip_for_live, {"submit_batches", "batch_status", "fetch_batches"} diff --git a/tests/test_recorrection_file.py b/tests/test_recorrection_file.py new file mode 100644 index 0000000..22e9ca2 --- /dev/null +++ b/tests/test_recorrection_file.py @@ -0,0 +1,34 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from copienator import read_json +from copienator_gui.recorrection import prepare_selection_file + + +class RecorrectionFileTests(unittest.TestCase): + def test_examples_are_inactive_and_opening_preserves_selection(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = prepare_selection_file(root) + self.assertEqual(read_json(path), []) + self.assertIn('// Une copie entière', path.read_text()) + selection = [["Copie46", ["II)9)"]]] + path.write_text(json.dumps(selection)) + prepare_selection_file(root) + self.assertEqual(read_json(path), selection) + before = path.read_bytes() + prepare_selection_file(root) + self.assertEqual(path.read_bytes(), before) + + def test_comments_do_not_affect_strings_or_other_json_files(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / "refaire.json" + path.write_text('// Example\n[["Copie01", ["a//b"]]]\n') + self.assertEqual(read_json(path), [["Copie01", ["a//b"]]]) + other = root / "correction.json" + other.write_text(path.read_text()) + with self.assertRaises(json.JSONDecodeError): + read_json(other)