utilities to fix LaTeX issues

This commit is contained in:
sebastien committed 2026-09-29 10:53:32 +02:00
1 parent 2a4c5f4f7c
commit a56fc6e680
7 files changed
+164 -2

No files matched your search

+10 -1
View File
@@ -48,6 +48,15 @@ def escape_latex_underscores(text: str) -> str:
return "".join(parts) 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: def normalize_overescaped_latex_commands(text: str) -> str:
r"""Collapse doubled command escapes inside LaTeX math environments. 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 = fast_fix(text, lookup).replace("\x00", "")
text = repair_json_escape_corruption(text) text = repair_json_escape_corruption(text)
text = normalize_overescaped_latex_commands(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: def clean_obj(value: Any, lookup: dict[str, str]) -> Any:
+10
View File
@@ -141,6 +141,13 @@ import subprocess
from .configuration import LATEX_AFTER, LATEX_BEFORE 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): def compile_to_pdf(text, output_pdf_path, *, strict=False):
"""Wraps text in standalone header/footer templates and compiles it to PDF.""" """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) shutil.move(generated_pdf, output_pdf_path)
if result.returncode != 0: if result.returncode != 0:
report_latex_warning(output_pdf_path)
lines = result.stdout.splitlines() lines = result.stdout.splitlines()
first = next((i for i, line in enumerate(lines) if line.lstrip().startswith("!")), None) 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 "" 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: if strict:
raise RuntimeError(f"LaTeX failed for {output_pdf_path}{detail}") raise RuntimeError(f"LaTeX failed for {output_pdf_path}{detail}")
elif not pdf_generated: elif not pdf_generated:
report_latex_warning(output_pdf_path)
print(f"Warning: LaTeX compilation produced no PDF for {output_pdf_path}") print(f"Warning: LaTeX compilation produced no PDF for {output_pdf_path}")
if strict: if strict:
raise RuntimeError(f"No PDF generated for {output_pdf_path}") raise RuntimeError(f"No PDF generated for {output_pdf_path}")
except Exception as e: except Exception as e:
if strict: if strict:
raise raise
report_latex_warning(output_pdf_path)
print(f"Compilation error for {output_pdf_path}: {e}") print(f"Compilation error for {output_pdf_path}: {e}")
+106
View File
@@ -20,6 +20,7 @@ from copienator.platform import (
open_path, open_path,
validate_windows_labels, validate_windows_labels,
) )
from copienator.utils import LATEX_WARNING_MARKER
from .diagnostics import collect_diagnostics from .diagnostics import collect_diagnostics
from .batch_monitor import BatchMonitor from .batch_monitor import BatchMonitor
@@ -281,6 +282,8 @@ class CopienatorApp(tk.Tk):
self.pending_refaire_commands: list[list[str]] = [] self.pending_refaire_commands: list[list[str]] = []
self.refaire_command_index = 0 self.refaire_command_index = 0
self._correction_progress_buffer = "" self._correction_progress_buffer = ""
self._latex_warning_buffer = ""
self._latex_warning_paths: list[Path] = []
self.title("Copienator — assistant de correction") self.title("Copienator — assistant de correction")
self.geometry("1180x820") 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)) 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 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) 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)) ttk.Label(console_actions, text="Réponse au script").grid(row=0, column=0, padx=(0, 6))
self.stdin_var = tk.StringVar() self.stdin_var = tk.StringVar()
self.stdin_entry = ttk.Entry(console_actions, textvariable=self.stdin_var) 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.") messagebox.showerror("Dossier invalide", "Choisissez un dossier d’évaluation existant.")
return return
self.batch_monitor.stop() self.batch_monitor.stop()
self._reset_latex_warnings()
self._save_current_form() self._save_current_form()
self.state_store.load(evaluation) self.state_store.load(evaluation)
for step in self.steps: for step in self.steps:
@@ -1532,6 +1543,8 @@ class CopienatorApp(tk.Tk):
last_run_values=run_values, last_run_values=run_values,
) )
self.active_step_id = step.id self.active_step_id = step.id
if step.id == "statement":
self._reset_latex_warnings()
if step.id == "correction": if step.id == "correction":
self._correction_progress_buffer = "" self._correction_progress_buffer = ""
self.info_var.set("Correction : préparation des groupes…") self.info_var.set("Correction : préparation des groupes…")
@@ -1642,6 +1655,7 @@ class CopienatorApp(tk.Tk):
if event == "output": if event == "output":
output = str(payload) output = str(payload)
self._track_correction_progress(output) self._track_correction_progress(output)
self._track_latex_warnings(output)
self._append_console(output) self._append_console(output)
elif event == "input_echo": elif event == "input_echo":
self._append_console(f"> {payload}") self._append_console(f"> {payload}")
@@ -1670,6 +1684,88 @@ class CopienatorApp(tk.Tk):
f"({percent} %)." 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: def _batch_watch_status_changed(self, message: str) -> None:
self.batch_watch_status.set(message) self.batch_watch_status.set(message)
self._update_controls() self._update_controls()
@@ -1710,6 +1806,8 @@ class CopienatorApp(tk.Tk):
step_id = self.active_step_id step_id = self.active_step_id
if not step_id: if not step_id:
return return
if step_id == "statement":
self._track_latex_warnings("", flush=True)
status = process_status(return_code, interrupted) status = process_status(return_code, interrupted)
if status == "success" and self.pending_refaire_commands: if status == "success" and self.pending_refaire_commands:
command = self.pending_refaire_commands.pop(0) command = self.pending_refaire_commands.pop(0)
@@ -1790,9 +1888,17 @@ class CopienatorApp(tk.Tk):
title = self.step_by_id[step_id].title title = self.step_by_id[step_id].title
self._append_console(f"\n[Terminé — code {return_code} — {STATUS_LABELS[status]}]\n") 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}).") 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.active_step_id = None
self._populate_tree() self._populate_tree()
self._update_controls() 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 step_id == "personal_read":
if status == "success": if status == "success":
self.info_var.set("Commande terminée. Poursuivez la relecture, puis cliquez sur « Marquer réussie ».") self.info_var.set("Commande terminée. Poursuivez la relecture, puis cliquez sur « Marquer réussie ».")
+1 -1
View File
@@ -706,7 +706,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"personal_deploy", "personal_deploy",
"Étapes personnelles", "Étapes personnelles",
"Déployer et publier les copies", "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"),), (manual("deploy"),),
personal=True, personal=True,
), ),
+23
View File
@@ -9,6 +9,7 @@ from copienator_gui.app import CopienatorApp
from copienator_gui.workflow import command_display from copienator_gui.workflow import command_display
from copienator.commands.page_splitter import _selected_inputs from copienator.commands.page_splitter import _selected_inputs
from copienator import EvaluationWorkspace from copienator import EvaluationWorkspace
from copienator.utils import LATEX_WARNING_MARKER
@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk requires a display") @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_persp")["status"], expected)
self.assertEqual(self.app.state_store.step("statement_groups")["status"], "ready") 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): def test_evaluation_buttons_are_ordered_and_open_folder_and_statement(self):
self.assertEqual(int(self.app.evaluation_entry.cget("width")), 42) self.assertEqual(int(self.app.evaluation_entry.cget("width")), 42)
self.assertEqual( self.assertEqual(
+12
View File
@@ -1785,6 +1785,18 @@ class StandardCliTests(unittest.TestCase):
r"valid $\begin{cases} A=0 \\ B=1 \end{cases}$", r"valid $\begin{cases} A=0 \\ B=1 \end{cases}$",
) )
self.assertEqual(module.clean_string(cleaned_text, {}), cleaned_text) 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 = ( corrupted_json_escapes = (
"Formulas $" "Formulas $"
+ "\x0c" + "\x0c"
+2
View File
@@ -38,6 +38,7 @@ class CompileToPdfTests(unittest.TestCase):
self.assertEqual(output.read_bytes(), b"partial") self.assertEqual(output.read_bytes(), b"partial")
self.assertIn("Warning: LaTeX compilation reported errors", console.getvalue()) 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("a PDF was generated", console.getvalue())
self.assertIn("missing \\item", console.getvalue()) self.assertIn("missing \\item", console.getvalue())
@@ -69,6 +70,7 @@ class CompileToPdfTests(unittest.TestCase):
self.assertFalse(output.exists()) self.assertFalse(output.exists())
self.assertIn("produced no PDF", console.getvalue()) self.assertIn("produced no PDF", console.getvalue())
self.assertIn(utils.LATEX_WARNING_MARKER + str(output.resolve()), console.getvalue())
if __name__ == "__main__": if __name__ == "__main__":