diff --git a/copienator/commands/post_correction.py b/copienator/commands/post_correction.py index 5532448..adc9908 100644 --- a/copienator/commands/post_correction.py +++ b/copienator/commands/post_correction.py @@ -48,6 +48,15 @@ def escape_latex_underscores(text: str) -> str: return "".join(parts) +def escape_latex_percents(text: str) -> str: + r"""Escape percent signs before annotation text is passed to LaTeX. + + Unlike underscores, percent signs are comments both inside and outside + math mode. Collapsing an existing escape run keeps cleanup idempotent. + """ + return re.sub(r"\\*%", lambda _match: r"\%", text) + + def normalize_overescaped_latex_commands(text: str) -> str: r"""Collapse doubled command escapes inside LaTeX math environments. @@ -118,7 +127,7 @@ def clean_string(text: str, lookup: dict[str, str]) -> str: text = fast_fix(text, lookup).replace("\x00", "") text = repair_json_escape_corruption(text) text = normalize_overescaped_latex_commands(text) - return escape_latex_underscores(text) + return escape_latex_percents(escape_latex_underscores(text)) def clean_obj(value: Any, lookup: dict[str, str]) -> Any: diff --git a/copienator/utils.py b/copienator/utils.py index 3009227..d1234f2 100644 --- a/copienator/utils.py +++ b/copienator/utils.py @@ -141,6 +141,13 @@ import subprocess from .configuration import LATEX_AFTER, LATEX_BEFORE +LATEX_WARNING_MARKER = "[Copienator:latex-warning] " + + +def report_latex_warning(output_pdf_path) -> None: + """Emit a stable, machine-readable path alongside human diagnostics.""" + print(f"{LATEX_WARNING_MARKER}{Path(output_pdf_path).resolve()}") + def compile_to_pdf(text, output_pdf_path, *, strict=False): """Wraps text in standalone header/footer templates and compiles it to PDF.""" @@ -185,6 +192,7 @@ def compile_to_pdf(text, output_pdf_path, *, strict=False): shutil.move(generated_pdf, output_pdf_path) if result.returncode != 0: + report_latex_warning(output_pdf_path) lines = result.stdout.splitlines() first = next((i for i, line in enumerate(lines) if line.lstrip().startswith("!")), None) detail = "\n" + "\n".join(lines[first:first + 5]) if first is not None else "" @@ -196,10 +204,12 @@ def compile_to_pdf(text, output_pdf_path, *, strict=False): if strict: raise RuntimeError(f"LaTeX failed for {output_pdf_path}{detail}") elif not pdf_generated: + report_latex_warning(output_pdf_path) print(f"Warning: LaTeX compilation produced no PDF for {output_pdf_path}") if strict: raise RuntimeError(f"No PDF generated for {output_pdf_path}") except Exception as e: if strict: raise + report_latex_warning(output_pdf_path) print(f"Compilation error for {output_pdf_path}: {e}") diff --git a/copienator_gui/app.py b/copienator_gui/app.py index f825206..b323323 100644 --- a/copienator_gui/app.py +++ b/copienator_gui/app.py @@ -20,6 +20,7 @@ from copienator.platform import ( open_path, validate_windows_labels, ) +from copienator.utils import LATEX_WARNING_MARKER from .diagnostics import collect_diagnostics from .batch_monitor import BatchMonitor @@ -281,6 +282,8 @@ class CopienatorApp(tk.Tk): self.pending_refaire_commands: list[list[str]] = [] self.refaire_command_index = 0 self._correction_progress_buffer = "" + self._latex_warning_buffer = "" + self._latex_warning_paths: list[Path] = [] self.title("Copienator — assistant de correction") self.geometry("1180x820") @@ -493,6 +496,13 @@ class CopienatorApp(tk.Tk): clipboard_actions.grid(row=1, column=0, columnspan=6, sticky="w", pady=(5, 0)) ttk.Button(clipboard_actions, text="Copier la sélection", command=self._copy_console_selection).pack(side="left") ttk.Button(clipboard_actions, text="Copier toute la sortie", command=self._copy_console_all).pack(side="left", padx=6) + self.latex_warning_button = ttk.Button( + clipboard_actions, + text="Problèmes LaTeX…", + command=self._show_current_latex_warnings, + ) + self.latex_warning_button.pack(side="left", padx=6) + self.latex_warning_button.pack_forget() ttk.Label(console_actions, text="Réponse au script").grid(row=0, column=0, padx=(0, 6)) self.stdin_var = tk.StringVar() self.stdin_entry = ttk.Entry(console_actions, textvariable=self.stdin_var) @@ -582,6 +592,7 @@ class CopienatorApp(tk.Tk): messagebox.showerror("Dossier invalide", "Choisissez un dossier d’évaluation existant.") return self.batch_monitor.stop() + self._reset_latex_warnings() self._save_current_form() self.state_store.load(evaluation) for step in self.steps: @@ -1532,6 +1543,8 @@ class CopienatorApp(tk.Tk): last_run_values=run_values, ) self.active_step_id = step.id + if step.id == "statement": + self._reset_latex_warnings() if step.id == "correction": self._correction_progress_buffer = "" self.info_var.set("Correction : préparation des groupes…") @@ -1642,6 +1655,7 @@ class CopienatorApp(tk.Tk): if event == "output": output = str(payload) self._track_correction_progress(output) + self._track_latex_warnings(output) self._append_console(output) elif event == "input_echo": self._append_console(f"> {payload}") @@ -1670,6 +1684,88 @@ class CopienatorApp(tk.Tk): f"({percent} %)." ) + def _reset_latex_warnings(self) -> None: + self._latex_warning_buffer = "" + self._latex_warning_paths = [] + if hasattr(self, "latex_warning_button"): + self.latex_warning_button.pack_forget() + + def _track_latex_warnings(self, output: str, *, flush: bool = False) -> None: + if self.active_step_id != "statement": + return + combined = self._latex_warning_buffer + output + lines = combined.splitlines(keepends=True) + self._latex_warning_buffer = "" + if lines and not lines[-1].endswith(("\n", "\r")) and not flush: + self._latex_warning_buffer = lines.pop() + for line in lines: + marker_at = line.find(LATEX_WARNING_MARKER) + if marker_at < 0: + continue + raw_path = line[marker_at + len(LATEX_WARNING_MARKER):].strip() + if not raw_path: + continue + path = Path(raw_path).expanduser() + if not path.is_absolute() and self.evaluation: + path = self.evaluation / path + path = path.resolve() + if path not in self._latex_warning_paths: + self._latex_warning_paths.append(path) + if self._latex_warning_paths: + self.latex_warning_button.pack(side="left", padx=6) + + def _show_current_latex_warnings(self) -> None: + if not self._latex_warning_paths: + messagebox.showinfo("Compilation LaTeX", "Aucun problème de compilation détecté.") + return + dialog = tk.Toplevel(self) + dialog.title("Problèmes de compilation LaTeX") + dialog.geometry("900x380") + dialog.transient(self) + dialog.columnconfigure(0, weight=1) + dialog.rowconfigure(1, weight=1) + ttk.Label( + dialog, + text=( + f"{len(self._latex_warning_paths)} fichier(s) n’ont pas été compilés " + "correctement. Sélectionnez-en un pour ouvrir ses éléments." + ), + padding=10, + wraplength=850, + justify="left", + ).grid(row=0, column=0, sticky="ew") + paths = tk.Listbox(dialog, exportselection=False) + for pdf_path in self._latex_warning_paths: + try: + label = str(pdf_path.relative_to(self.evaluation)) if self.evaluation else str(pdf_path) + except ValueError: + label = str(pdf_path) + paths.insert("end", label) + paths.grid(row=1, column=0, sticky="nsew", padx=10) + paths.selection_set(0) + + actions = ttk.Frame(dialog, padding=10) + actions.grid(row=2, column=0, sticky="ew") + + def selected_pdf() -> Path: + selection = paths.curselection() + index = selection[0] if selection else 0 + return self._latex_warning_paths[index] + + def open_folder() -> None: + self._open_desktop_path(selected_pdf().parent, "dossier de compilation") + + def open_source() -> None: + self._open_desktop_path(selected_pdf().with_suffix(".tex"), "fichier source LaTeX") + + def open_pdf() -> None: + self._open_desktop_path(selected_pdf(), "fichier PDF compilé") + + ttk.Button(actions, text="Ouvrir le dossier", command=open_folder).pack(side="left") + ttk.Button(actions, text="Ouvrir le .tex", command=open_source).pack(side="left", padx=6) + ttk.Button(actions, text="Ouvrir le PDF", command=open_pdf).pack(side="left") + ttk.Button(actions, text="Fermer", command=dialog.destroy).pack(side="right") + def _batch_watch_status_changed(self, message: str) -> None: self.batch_watch_status.set(message) self._update_controls() @@ -1710,6 +1806,8 @@ class CopienatorApp(tk.Tk): step_id = self.active_step_id if not step_id: return + if step_id == "statement": + self._track_latex_warnings("", flush=True) status = process_status(return_code, interrupted) if status == "success" and self.pending_refaire_commands: command = self.pending_refaire_commands.pop(0) @@ -1790,9 +1888,17 @@ class CopienatorApp(tk.Tk): title = self.step_by_id[step_id].title self._append_console(f"\n[Terminé — code {return_code} — {STATUS_LABELS[status]}]\n") self.info_var.set(f"{title} : {STATUS_LABELS[status].lower()} (code {return_code}).") + if step_id == "statement" and self._latex_warning_paths: + count = len(self._latex_warning_paths) + self.info_var.set( + f"{title} : {STATUS_LABELS[status].lower()} (code {return_code}) — " + f"{count} problème(s) LaTeX." + ) self.active_step_id = None self._populate_tree() self._update_controls() + if step_id == "statement" and self._latex_warning_paths: + self.after_idle(self._show_current_latex_warnings) if step_id == "personal_read": if status == "success": self.info_var.set("Commande terminée. Poursuivez la relecture, puis cliquez sur « Marquer réussie ».") diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index 91bb474..f135b9b 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -706,7 +706,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "personal_deploy", "Étapes personnelles", "Déployer et publier les copies", - "Étape manuelle : déployer miqmacs-copies-assets puis mettre à jour les copies depuis l’administration.", + "Étape manuelle : déployer miqmacs-copies-assets et la section `Interro` (inutile : mettre à jour les copies depuis l’administration).", (manual("deploy"),), personal=True, ), diff --git a/tests/test_gui_convenience.py b/tests/test_gui_convenience.py index d235354..64efac9 100644 --- a/tests/test_gui_convenience.py +++ b/tests/test_gui_convenience.py @@ -9,6 +9,7 @@ from copienator_gui.app import CopienatorApp from copienator_gui.workflow import command_display from copienator.commands.page_splitter import _selected_inputs from copienator import EvaluationWorkspace +from copienator.utils import LATEX_WARNING_MARKER @unittest.skipUnless(os.environ.get("DISPLAY"), "Tk requires a display") @@ -99,6 +100,28 @@ class GuiConvenienceTests(unittest.TestCase): self.assertEqual(self.app.state_store.step("statement_persp")["status"], expected) self.assertEqual(self.app.state_store.step("statement_groups")["status"], "ready") + def test_statement_latex_warnings_are_tracked_for_both_variants(self): + for variant in ("gemini", "personal"): + with self.subTest(variant=variant): + self.app._reset_latex_warnings() + self.app.state_store.update_step("statement", last_run_variant=variant) + self.app.active_step_id = "statement" + first = self.evaluation / "Text2" / "Ex 1.pdf" + second = self.evaluation / "Sol2" / "Ex 2.pdf" + marker = LATEX_WARNING_MARKER + self.app._track_latex_warnings(f"noise\n{marker}{first}") + self.assertEqual(self.app._latex_warning_paths, []) + self.app._track_latex_warnings(f"\n{marker}{second}\n{marker}{first}\n") + self.assertEqual(self.app._latex_warning_paths, [first, second]) + self.assertTrue(self.app.latex_warning_button.winfo_manager()) + with patch.object(self.app, "after_idle") as after_idle: + self.app._finish_process(4, False) + self.assertTrue( + any(call.args[0] == self.app._show_current_latex_warnings + for call in after_idle.call_args_list) + ) + self.assertIn("2 problème(s) LaTeX", self.app.info_var.get()) + def test_evaluation_buttons_are_ordered_and_open_folder_and_statement(self): self.assertEqual(int(self.app.evaluation_entry.cget("width")), 42) self.assertEqual( diff --git a/tests/test_gui_core.py b/tests/test_gui_core.py index 9fc785a..4b2b3e4 100644 --- a/tests/test_gui_core.py +++ b/tests/test_gui_core.py @@ -1785,6 +1785,18 @@ class StandardCliTests(unittest.TestCase): r"valid $\begin{cases} A=0 \\ B=1 \end{cases}$", ) self.assertEqual(module.clean_string(cleaned_text, {}), cleaned_text) + percent_text = r"Code `n % p`, prose 50%, math $n \% p$ and $50%$." + escaped_percent_text = ( + r"Code `n \% p`, prose 50\%, math $n \% p$ and $50\%$." + ) + self.assertEqual( + module.clean_string(percent_text, {}), + escaped_percent_text, + ) + self.assertEqual( + module.clean_string(escaped_percent_text, {}), + escaped_percent_text, + ) corrupted_json_escapes = ( "Formulas $" + "\x0c" diff --git a/tests/test_utils_compile.py b/tests/test_utils_compile.py index 1dd515f..55efc07 100644 --- a/tests/test_utils_compile.py +++ b/tests/test_utils_compile.py @@ -38,6 +38,7 @@ class CompileToPdfTests(unittest.TestCase): self.assertEqual(output.read_bytes(), b"partial") self.assertIn("Warning: LaTeX compilation reported errors", console.getvalue()) + self.assertIn(utils.LATEX_WARNING_MARKER + str(output.resolve()), console.getvalue()) self.assertIn("a PDF was generated", console.getvalue()) self.assertIn("missing \\item", console.getvalue()) @@ -69,6 +70,7 @@ class CompileToPdfTests(unittest.TestCase): self.assertFalse(output.exists()) self.assertIn("produced no PDF", console.getvalue()) + self.assertIn(utils.LATEX_WARNING_MARKER + str(output.resolve()), console.getvalue()) if __name__ == "__main__":